v1.2 mobile PWA: installable app + mobile-first UI

- Installable PWA: manifest.json (standalone, portrait, maskable icons),
  theme-color + apple-mobile-web-app meta, generated 192/512 PNG icons,
  service worker (network-first, never caches /api/).
- Mobile UI (≤720px): sticky blurred header, agent grid -> horizontal
  snap-scroll carousel, fixed bottom composer with safe-area insets,
  modals -> full-screen bottom sheets, 16px inputs (no iOS zoom), bigger
  touch targets, icon-only header actions.
- /app remains the app shell; / is the landing page.
This commit is contained in:
drjones
2026-09-07 20:00:46 -07:00
parent f02124c9de
commit 7a6ba23c99
5 changed files with 101 additions and 3 deletions

BIN
static/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
static/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

14
static/manifest.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "Astraea — Washington Family Law Assistant",
"short_name": "Astraea",
"description": "Eight specialist AI attorneys grounded in Washington statute and case law. Not legal advice.",
"start_url": "/",
"display": "standalone",
"background_color": "#070b1a",
"theme_color": "#070b1a",
"orientation": "portrait-primary",
"icons": [
{ "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}

31
static/sw.js Normal file
View File

@@ -0,0 +1,31 @@
const CACHE = 'astraea-v1';
const ASSETS = ['/', '/static/manifest.json', '/static/icon-192.png', '/static/icon-512.png'];
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
// Network-first for everything except static assets (the app shell is tiny;
// API responses must never be served stale).
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
if (e.request.method !== 'GET') return;
if (url.pathname.startsWith('/api/')) return; // always live
e.respondWith(
fetch(e.request)
.then((resp) => {
const copy = resp.clone();
caches.open(CACHE).then((c) => c.put(e.request, copy));
return resp;
})
.catch(() => caches.match(e.request).then((c) => c || caches.match('/')))
);
});

View File

@@ -2,7 +2,15 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#070b1a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Astraea">
<meta name="mobile-web-app-capable" content="yes">
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<link rel="icon" type="image/png" href="/static/icon-192.png">
<title>Astraea — Your Attorney</title> <title>Astraea — Your Attorney</title>
<style> <style>
:root { --bg:#070b1a; --panel:#101832; --panel2:#141d3d; --line:#233055; --text:#e8edfb; --muted:#8b98c4; --gold:#f5d06f; } :root { --bg:#070b1a; --panel:#101832; --panel2:#141d3d; --line:#233055; --text:#e8edfb; --muted:#8b98c4; --gold:#f5d06f; }
@@ -102,6 +110,47 @@
footer { text-align:center; margin-top:26px; font-size:12px; color:var(--muted); } footer { text-align:center; margin-top:26px; font-size:12px; color:var(--muted); }
footer a { color:var(--muted); } footer a { color:var(--muted); }
@media (max-width:640px){ .msg{max-width:92%;} .grid2{grid-template-columns:1fr;} .avatar-grid{grid-template-columns:repeat(4,1fr);} } @media (max-width:640px){ .msg{max-width:92%;} .grid2{grid-template-columns:1fr;} .avatar-grid{grid-template-columns:repeat(4,1fr);} }
/* ───────────────────────── Mobile app experience (≤720px) ───────────────────────── */
@media (max-width:720px){
body { background:var(--bg); overscroll-behavior-y:none; }
.app { max-width:100%; padding:0 0 120px; }
/* sticky, blurred header */
header { position:sticky; top:0; z-index:20; padding:12px 14px; padding-top:calc(12px + env(safe-area-inset-top)); background:rgba(7,11,26,.82); backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px); border-bottom:1px solid var(--line); margin-bottom:4px; }
.logo { width:36px; height:36px; font-size:18px; }
.brand h1 { font-size:17px; }
.top-actions .btn { padding:8px 11px; font-size:12px; }
.top-actions .btn .lbl { display:none; }
.hero { padding:8px 14px 6px; }
.hero h2 { font-size:21px; }
.hero p { font-size:13.5px; }
.label { padding:0 14px; margin:10px 0 10px; }
/* agents -> horizontal snap carousel */
.grid { display:flex; overflow-x:auto; scroll-snap-type:x mandatory; gap:11px; padding:2px 14px 10px; -webkit-overflow-scrolling:touch; scrollbar-width:none; }
.grid::-webkit-scrollbar { display:none; }
.card { flex:0 0 78%; scroll-snap-align:center; padding:15px; }
.card:hover { transform:none; }
.chat-panel { margin-top:6px; border-radius:18px 18px 0 0; border-left:none; border-right:none; border-bottom:none; }
.messages { max-height:none; height:calc(100vh - 300px); min-height:200px; padding:14px; }
.chat-head { padding:11px 14px; }
.chips { order:3; width:100%; margin-left:0; padding-top:8px; }
/* fixed composer with safe-area */
.composer { position:fixed; left:0; right:0; bottom:0; z-index:25; background:rgba(16,24,50,.96); backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px); border-top:1px solid var(--line); padding:10px 12px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); }
.composer textarea { font-size:16px; } /* 16px prevents iOS zoom-on-focus */
.send { padding:12px 16px; }
.tools-row { overflow-x:auto; flex-wrap:nowrap; padding:0 14px 10px; scrollbar-width:none; }
.tools-row .btn { flex:0 0 auto; }
/* modals -> full-screen bottom sheets */
.modal-bg { align-items:flex-end; padding:0; }
.modal { max-width:100%; width:100%; max-height:92vh; border-radius:22px 22px 0 0; padding:22px 18px; padding-bottom:calc(24px + env(safe-area-inset-bottom)); }
.modal.wide { width:100%; max-height:92vh; }
.modal .close { font-size:26px; padding:0 4px; }
.avatar-grid { grid-template-columns:repeat(4,1fr); }
/* bigger touch targets */
.btn, .chip, .send, .speak, .cite-toggle { min-height:40px; }
.chip { font-size:12px; padding:7px 12px; }
footer { padding:0 14px 20px; font-size:11px; }
}
</style> </style>
</head> </head>
<body> <body>
@@ -110,8 +159,8 @@
<header> <header>
<div class="brand"><div class="logo">⚖️</div><div><h1>ASTRA<span>EA</span></h1></div></div> <div class="brand"><div class="logo">⚖️</div><div><h1>ASTRA<span>EA</span></h1></div></div>
<div class="top-actions"> <div class="top-actions">
<button class="btn" onclick="openDocs()">📄 Documents</button> <button class="btn" onclick="openDocs()">📄 <span class="lbl">Documents</span></button>
<button class="btn" onclick="openSettings()">⚙️ Settings</button> <button class="btn" onclick="openSettings()">⚙️ <span class="lbl">Settings</span></button>
<div class="avatar-sm" id="myAvatar" onclick="openProfile()" style="background:linear-gradient(135deg,#6ea8ff,#a78bfa)">⚖</div> <div class="avatar-sm" id="myAvatar" onclick="openProfile()" style="background:linear-gradient(135deg,#6ea8ff,#a78bfa)">⚖</div>
</div> </div>
</header> </header>
@@ -241,6 +290,10 @@
</div></div> </div></div>
<script> <script>
// Register the service worker for installable PWA (skips on non-secure origins)
if ('serviceWorker' in navigator && location.protocol === 'https:') {
navigator.serviceWorker.register('/static/sw.js').catch(() => {});
}
const AGENTS = {{ agents | tojson }}; const AGENTS = {{ agents | tojson }};
const token = localStorage.getItem('astraea_token') || ''; const token = localStorage.getItem('astraea_token') || '';
let active = AGENTS[0].id; let active = AGENTS[0].id;