kin: BATON-ARCH-009-frontend_dev

This commit is contained in:
Gros Frumos 2026-03-20 21:09:05 +02:00
parent 8ecaeeafc6
commit 6dff5de077
7 changed files with 625 additions and 0 deletions

264
frontend/app.js Normal file
View file

@ -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();
});

BIN
frontend/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 B

BIN
frontend/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

78
frontend/index.html Normal file
View file

@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Emergency signal button">
<!-- PWA meta tags -->
<meta name="theme-color" content="#ff0000">
<meta name="mobile-web-app-capable" content="yes">
<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="Baton">
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
<link rel="stylesheet" href="/style.css">
<title>Baton</title>
</head>
<body>
<!-- Private mode banner: shown when localStorage is unavailable (decision #1041).
Explicit action guidance: user must open in normal mode to retain registration. -->
<div id="private-mode-banner" class="private-banner" hidden>
&#9888;&#65039; Private mode detected. Your registration will be lost when this tab
closes. To keep your data, open Baton in a regular (non-private) browser tab.
</div>
<!-- Shared top bar: user initials avatar + network status indicator -->
<header class="topbar">
<div class="user-avatar" id="user-avatar" aria-label="User">?</div>
<div class="network-indicator" id="indicator-network"
role="status" aria-label="Network status"></div>
</header>
<!-- Onboarding screen: shown on first visit (no UUID registered yet) -->
<div id="screen-onboarding" class="screen" role="main" hidden>
<div class="screen-content">
<input
type="text"
id="name-input"
class="name-input"
placeholder="Your name"
maxlength="100"
autocomplete="name"
autocorrect="off"
autocapitalize="words"
spellcheck="false"
aria-label="Your name"
>
<button type="button" id="btn-confirm" class="btn-confirm" disabled>
Confirm
</button>
</div>
</div>
<!-- Main screen: SOS button -->
<div id="screen-main" class="screen" role="main" hidden>
<div class="screen-content">
<button
type="button"
id="btn-sos"
class="btn-sos"
data-state="default"
aria-label="Send emergency signal"
>
SOS
</button>
</div>
</div>
<!-- Status message: errors and confirmations -->
<div id="status" class="status" role="alert" aria-live="polite" hidden></div>
<script src="/app.js"></script>
</body>
</html>

23
frontend/manifest.json Normal file
View file

@ -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"
}
]
}

200
frontend/style.css Normal file
View file

@ -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; }

60
frontend/sw.js Normal file
View file

@ -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;
});
})
);
});