From 6dff5de0778c7496bad56161eaaeb468e9a47e08 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 21:09:05 +0200 Subject: [PATCH] kin: BATON-ARCH-009-frontend_dev --- frontend/app.js | 264 ++++++++++++++++++++++++++++++++++++ frontend/icons/icon-192.png | Bin 0 -> 412 bytes frontend/icons/icon-512.png | Bin 0 -> 1496 bytes frontend/index.html | 78 +++++++++++ frontend/manifest.json | 23 ++++ frontend/style.css | 200 +++++++++++++++++++++++++++ frontend/sw.js | 60 ++++++++ 7 files changed, 625 insertions(+) create mode 100644 frontend/app.js create mode 100644 frontend/icons/icon-192.png create mode 100644 frontend/icons/icon-512.png create mode 100644 frontend/index.html create mode 100644 frontend/manifest.json create mode 100644 frontend/style.css create mode 100644 frontend/sw.js diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..10c4b1b --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,264 @@ +'use strict'; + +// ========== Storage abstraction (decisions #1024, #1025) ========== +// +// #1024: probe availability with a real write, NOT typeof/in +// #1025: fallback chain: localStorage → sessionStorage → in-memory + +let _storage = null; +let _storageType = 'memory'; +const _mem = {}; + +function _probeStorage(s) { + try { + const k = '__baton_probe__'; + s.setItem(k, '1'); + s.removeItem(k); + return true; + } catch (_) { + return false; + } +} + +function _initStorage() { + if (_probeStorage(localStorage)) { + _storage = localStorage; + _storageType = 'local'; + } else if (_probeStorage(sessionStorage)) { + _storage = sessionStorage; + _storageType = 'session'; + } else { + _storage = { + getItem: (k) => (Object.prototype.hasOwnProperty.call(_mem, k) ? _mem[k] : null), + setItem: (k, v) => { _mem[k] = String(v); }, + removeItem: (k) => { delete _mem[k]; }, + }; + _storageType = 'memory'; + } +} + +// ========== User identity ========== + +function _getOrCreateUserId() { + let id = _storage.getItem('baton_user_id'); + if (!id) { + id = crypto.randomUUID(); + _storage.setItem('baton_user_id', id); + } + return id; +} + +function _isRegistered() { + return _storage.getItem('baton_registered') === '1'; +} + +function _getUserName() { + return _storage.getItem('baton_user_name') || ''; +} + +function _saveRegistration(name) { + _storage.setItem('baton_user_name', name); + _storage.setItem('baton_registered', '1'); +} + +function _getInitials(name) { + const trimmed = (name || '').trim(); + if (!trimmed) return '?'; + return trimmed + .split(/\s+/) + .map((w) => w[0] || '') + .join('') + .toUpperCase() + .slice(0, 2); +} + +// ========== UI helpers ========== + +function _showScreen(id) { + document.querySelectorAll('.screen').forEach((el) => { + el.hidden = el.id !== id; + }); +} + +function _setStatus(msg, cls) { + const el = document.getElementById('status'); + el.textContent = msg; + el.className = 'status' + (cls ? ' status--' + cls : ''); + el.hidden = !msg; +} + +function _updateNetworkIndicator() { + const el = document.getElementById('indicator-network'); + if (!el) return; + el.className = 'network-indicator ' + (navigator.onLine ? 'online' : 'offline'); + el.setAttribute('aria-label', navigator.onLine ? 'Online' : 'Offline'); +} + +function _updateUserAvatar() { + const el = document.getElementById('user-avatar'); + if (!el) return; + el.textContent = _getInitials(_getUserName()); +} + +// ========== API calls ========== + +async function _apiPost(path, body) { + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error('HTTP ' + res.status + (text ? ': ' + text : '')); + } + return res.json(); +} + +// ========== Geolocation (optional, non-blocking) ========== + +function _getGeo() { + return new Promise((resolve) => { + if (!navigator.geolocation) { resolve(null); return; } + navigator.geolocation.getCurrentPosition( + ({ coords }) => + resolve({ + lat: coords.latitude, + lon: coords.longitude, + accuracy: coords.accuracy, + }), + () => resolve(null), + { timeout: 5000, maximumAge: 30000 } + ); + }); +} + +// ========== Handlers ========== + +async function _handleRegister() { + const input = document.getElementById('name-input'); + const btn = document.getElementById('btn-confirm'); + const name = input.value.trim(); + if (!name) return; + + btn.disabled = true; + _setStatus('', ''); + + try { + const uuid = _getOrCreateUserId(); + await _apiPost('/api/register', { uuid, name }); + _saveRegistration(name); + _updateUserAvatar(); + _showMain(); + } catch (_) { + _setStatus('Error. Please try again.', 'error'); + btn.disabled = false; + } +} + +function _setSosState(state) { + const btn = document.getElementById('btn-sos'); + if (!btn) return; + btn.dataset.state = state; + btn.disabled = state === 'sending'; +} + +async function _handleSignal() { + // v1: no offline queue — show error and return (decision #1019) + if (!navigator.onLine) { + _setStatus('No connection. Check your network and try again.', 'error'); + return; + } + + _setSosState('sending'); + _setStatus('', ''); + + try { + const geo = await _getGeo(); + const uuid = _getOrCreateUserId(); + const body = { user_id: uuid, timestamp: Date.now() }; + if (geo) body.geo = geo; + + await _apiPost('/api/signal', body); + + _setSosState('success'); + _setStatus('Signal sent!', 'success'); + setTimeout(() => { + _setSosState('default'); + _setStatus('', ''); + }, 2000); + } catch (_) { + _setSosState('default'); + _setStatus('Error sending. Try again.', 'error'); + } +} + +// ========== Screens ========== + +function _showOnboarding() { + _showScreen('screen-onboarding'); + + const input = document.getElementById('name-input'); + const btn = document.getElementById('btn-confirm'); + + input.addEventListener('input', () => { + btn.disabled = input.value.trim().length === 0; + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !btn.disabled) _handleRegister(); + }); + btn.addEventListener('click', _handleRegister); +} + +function _showMain() { + _showScreen('screen-main'); + _updateUserAvatar(); + + const btn = document.getElementById('btn-sos'); + if (btn && !btn.dataset.listenerAttached) { + btn.addEventListener('click', _handleSignal); + btn.dataset.listenerAttached = '1'; + } +} + +// ========== Service Worker ========== + +function _registerSW() { + if (!('serviceWorker' in navigator)) return; + navigator.serviceWorker.register('/sw.js').catch((err) => { + console.warn('[baton] SW registration failed:', err); + }); +} + +// ========== Init ========== + +function _init() { + _initStorage(); + + // Pre-generate and persist UUID on first visit (per arch spec flow) + _getOrCreateUserId(); + + // Private mode graceful degradation (decision #1041): + // show inline banner with explicit action guidance when localStorage is unavailable + if (_storageType !== 'local') { + const banner = document.getElementById('private-mode-banner'); + if (banner) banner.hidden = false; + } + + // Network indicator — initial state + live updates + _updateNetworkIndicator(); + window.addEventListener('online', _updateNetworkIndicator); + window.addEventListener('offline', _updateNetworkIndicator); + + // Route to correct screen + if (_isRegistered()) { + _showMain(); + } else { + _showOnboarding(); + } +} + +document.addEventListener('DOMContentLoaded', () => { + _registerSW(); + _init(); +}); diff --git a/frontend/icons/icon-192.png b/frontend/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..0ddef3452d21347c82b59a5ed72b97816131f4ec GIT binary patch literal 412 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rficznGbb^5)^n^8Vv*_IG7U^cv#pDIRIIWKmi~ND#gYGl4@x@0Fp{n z082@5fOR1h(Lz7W5_0XwtDkvg1Bx%Pc>uow&`>Ja8tc+&;;?tO00R(sy85}Sb4q9e E0N@y9g#Z8m literal 0 HcmV?d00001 diff --git a/frontend/icons/icon-512.png b/frontend/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..7c2d6804a5cf3b7984104274af611a5ffc553d34 GIT binary patch literal 1496 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(1_svoo-U3d6}R4AHRNSr;5ocu zk!HODvngM7`bmyYDwml)1gJ0=03{e0m>CW*kj11}yC_B016@st>p|KbSjqQ2B+$qX zJgR8tAvfwEwo|Mf7Z0*WHhfPs@D{h&lZK@I?EANVvBzzB;@GE?)wY9H`KM1@2^ dY8rZw#`uuMaiNOh(i{dL@O1TaS?83{1OSwBmnZ-L literal 0 HcmV?d00001 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e5fe30e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + Baton + + + + + + + +
+
?
+
+
+ + + + + + + + + + + + + diff --git a/frontend/manifest.json b/frontend/manifest.json new file mode 100644 index 0000000..9655cea --- /dev/null +++ b/frontend/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "Baton", + "short_name": "Baton", + "description": "Emergency signal button", + "display": "standalone", + "orientation": "portrait-primary", + "start_url": "/", + "background_color": "#000000", + "theme_color": "#ff0000", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..487a443 --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,200 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #000000; + --text: #ffffff; + --muted: #9ca3af; + --input-bg: #1a1a1a; + --border: #374151; + --border-focus: #6b7280; + --confirm-bg: #374151; + --confirm-active: #4b5563; + --sos: #cc2626; + --sos-pressed: #991c1c; + --sos-success: #16a34a; + --banner-bg: #7c2d12; + --banner-text: #fed7aa; +} + +html, body { + height: 100%; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + -webkit-tap-highlight-color: transparent; + overscroll-behavior: none; + user-select: none; +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; + /* Use dynamic viewport height on mobile to account for browser chrome */ + min-height: 100dvh; +} + +/* ===== Private mode banner (decision #1041) ===== */ + +.private-banner { + background: var(--banner-bg); + color: var(--banner-text); + padding: 10px 16px; + font-size: 13px; + line-height: 1.5; + text-align: center; + flex-shrink: 0; +} + +.private-banner[hidden] { display: none; } + +/* ===== Top bar ===== */ + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + flex-shrink: 0; +} + +.user-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: #374151; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; + color: var(--text); + letter-spacing: 0; +} + +.network-indicator { + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--muted); + transition: background 0.3s ease; +} + +.network-indicator.online { background: #16a34a; } +.network-indicator.offline { background: #4b5563; } + +/* ===== Screens ===== */ + +.screen { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.screen[hidden] { display: none; } + +.screen-content { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 24px 20px; +} + +/* ===== Onboarding ===== */ + +.name-input { + width: 100%; + max-width: 320px; + padding: 14px 16px; + background: var(--input-bg); + border: 1.5px solid var(--border); + border-radius: 12px; + color: var(--text); + font-size: 17px; + outline: none; + transition: border-color 0.15s; + -webkit-appearance: none; + user-select: text; +} + +.name-input::placeholder { color: var(--muted); } +.name-input:focus { border-color: var(--border-focus); } + +.btn-confirm { + width: 100%; + max-width: 320px; + padding: 14px; + background: var(--confirm-bg); + border: none; + border-radius: 12px; + color: var(--text); + font-size: 17px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; +} + +.btn-confirm:disabled { opacity: 0.35; cursor: default; } +.btn-confirm:not(:disabled):active { background: var(--confirm-active); } + +/* ===== SOS button (min 60vmin × 60vmin per UX spec) ===== */ + +.btn-sos { + width: 60vmin; + height: 60vmin; + min-width: 180px; + min-height: 180px; + border-radius: 50%; + border: none; + background: var(--sos); + color: #fff; + font-size: clamp(24px, 9vmin, 60px); + font-weight: 900; + letter-spacing: 0.08em; + cursor: pointer; + touch-action: manipulation; + transition: background 0.15s ease, transform 0.1s ease; +} + +.btn-sos:active, +.btn-sos[data-state="sending"] { + background: var(--sos-pressed); + transform: scale(0.96); +} + +.btn-sos[data-state="sending"] { + animation: baton-pulse 1s ease-in-out infinite; + cursor: default; + pointer-events: none; +} + +.btn-sos[data-state="success"] { + background: var(--sos-success); + transform: none; +} + +@keyframes baton-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +/* ===== Status message ===== */ + +.status { + padding: 12px 20px; + text-align: center; + font-size: 14px; + flex-shrink: 0; +} + +.status[hidden] { display: none; } +.status--error { color: #f87171; } +.status--success { color: #4ade80; } diff --git a/frontend/sw.js b/frontend/sw.js new file mode 100644 index 0000000..e37d2fa --- /dev/null +++ b/frontend/sw.js @@ -0,0 +1,60 @@ +'use strict'; + +const CACHE_NAME = 'baton-v1'; + +// App shell assets to precache +const APP_SHELL = [ + '/', + '/index.html', + '/app.js', + '/style.css', + '/manifest.json', + '/icons/icon-192.png', + '/icons/icon-512.png', +]; + +// Install: precache app shell + skipWaiting so new SW activates immediately +self.addEventListener('install', (event) => { + self.skipWaiting(); + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)) + ); +}); + +// Activate: delete stale caches + claim open clients (decision: skipWaiting + clients.claim) +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) + ) + ) + .then(() => self.clients.claim()) + ); +}); + +// Fetch: cache-first for app shell; API calls pass through to network +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') return; + + const url = new URL(event.request.url); + + // Never intercept API calls — they must always reach the server + if (url.pathname.startsWith('/api/')) return; + + event.respondWith( + caches.match(event.request).then((cached) => { + if (cached) return cached; + + return fetch(event.request).then((response) => { + if (response && response.status === 200) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + } + return response; + }); + }) + ); +});