From 3c675164069ece0d443bcc04e009c619f14badab Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:08:02 -0400 Subject: [PATCH] feat: add sovereignty score display HUD (#129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a bottom-left HUD panel that computes and renders a 0–100 sovereignty score showing how local-first the Nexus setup is. Factors scored: - Local host detection (40 pts) - WebSocket on localhost (30 pts) - Service worker / offline capable (20 pts) - localStorage accessible (10 pts) Score bar color transitions: red (<40), amber (<70), blue (≥70). Fixes #129 Co-Authored-By: Claude Sonnet 4.6 --- app.js | 86 +++++++++++++++++++++++++++++++++++++++++++++++ index.html | 8 +++++ style.css | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+) diff --git a/app.js b/app.js index 5a028f6..6ff872f 100644 --- a/app.js +++ b/app.js @@ -209,6 +209,92 @@ document.getElementById('debug-toggle').addEventListener('click', () => { } }); +// === SOVEREIGNTY SCORE === +/** + * Computes a sovereignty score (0–100) based on how local-first the current + * environment is. Factors: local host, WS on localhost, service worker, and + * persistent local storage. + */ +async function computeSovereigntyScore() { + const factors = []; + + // Factor 1 — local hosting (40 pts) + const isLocalHost = ['localhost', '127.0.0.1', '::1'].includes(location.hostname); + factors.push({ + label: 'LOCAL HOST', + pts: isLocalHost ? 40 : 0, + max: 40, + status: isLocalHost ? 'on' : 'off', + }); + + // Factor 2 — WebSocket on local server (30 pts) + const wsHostLocal = typeof wsClient !== 'undefined' && wsClient.url + ? ['localhost', '127.0.0.1'].some(h => wsClient.url.includes(h)) + : isLocalHost; // fall back to page host + factors.push({ + label: 'LOCAL WS', + pts: wsHostLocal ? 30 : 0, + max: 30, + status: wsHostLocal ? 'on' : 'off', + }); + + // Factor 3 — Service worker / offline capable (20 pts) + let hasSW = false; + if ('serviceWorker' in navigator) { + try { + const reg = await navigator.serviceWorker.getRegistration(); + hasSW = !!reg; + } catch (_) { /* ignore */ } + } + factors.push({ + label: 'OFFLINE SW', + pts: hasSW ? 20 : 0, + max: 20, + status: hasSW ? 'on' : 'warn', + }); + + // Factor 4 — Local storage accessible (10 pts) + let hasStorage = false; + try { + localStorage.setItem('_sov_check', '1'); + localStorage.removeItem('_sov_check'); + hasStorage = true; + } catch (_) { /* blocked */ } + factors.push({ + label: 'LOCAL STORE', + pts: hasStorage ? 10 : 0, + max: 10, + status: hasStorage ? 'on' : 'off', + }); + + const total = factors.reduce((sum, f) => sum + f.pts, 0); + return { total, factors }; +} + +function renderSovereigntyScore({ total, factors }) { + const scoreEl = document.getElementById('sov-score-value'); + const barEl = document.getElementById('sov-bar-fill'); + const listEl = document.getElementById('sov-factors'); + + scoreEl.textContent = total; + barEl.style.width = `${total}%`; + + barEl.className = 'sov-bar-fill'; + if (total < 40) barEl.classList.add('sov-low'); + else if (total < 70) barEl.classList.add('sov-mid'); + else barEl.classList.add('sov-high'); + + listEl.innerHTML = ''; + for (const f of factors) { + const li = document.createElement('li'); + if (f.pts > 0) li.classList.add('active'); + li.innerHTML = `${f.label}`; + listEl.appendChild(li); + } +} + +computeSovereigntyScore().then(renderSovereigntyScore); + // === WEBSOCKET CLIENT === import { wsClient } from './ws-client.js'; diff --git a/index.html b/index.html index 26344f3..009cddc 100644 --- a/index.html +++ b/index.html @@ -41,6 +41,14 @@ [Tab] to exit + +
+
SOVEREIGNTY
+
--/100
+
+
    +
    + diff --git a/style.css b/style.css index 1d78e52..5e95869 100644 --- a/style.css +++ b/style.css @@ -106,3 +106,101 @@ canvas { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } } + +/* === SOVEREIGNTY SCORE === */ +#sovereignty-display { + position: fixed; + bottom: 16px; + left: 16px; + z-index: 10; + background: rgba(0, 0, 8, 0.75); + border: 1px solid var(--color-secondary); + padding: 8px 12px; + font-family: var(--font-body); + font-size: 10px; + letter-spacing: 0.12em; + color: var(--color-text); + min-width: 140px; + pointer-events: none; + animation: sov-idle 4s ease-in-out infinite; +} + +.sov-header { + color: var(--color-primary); + font-size: 9px; + letter-spacing: 0.25em; + margin-bottom: 4px; + text-transform: uppercase; +} + +.sov-score { + font-size: 22px; + font-weight: bold; + line-height: 1; + margin-bottom: 6px; + color: var(--color-primary); +} + +.sov-max { + font-size: 11px; + color: var(--color-text-muted); + margin-left: 2px; +} + +.sov-bar-track { + width: 100%; + height: 3px; + background: var(--color-secondary); + margin-bottom: 7px; + border-radius: 2px; + overflow: hidden; +} + +.sov-bar-fill { + height: 100%; + width: 0%; + background: var(--color-primary); + border-radius: 2px; + transition: width 0.8s ease, background-color 0.8s ease; +} + +.sov-bar-fill.sov-low { background: #ff4444; } +.sov-bar-fill.sov-mid { background: #ffaa00; } +.sov-bar-fill.sov-high { background: var(--color-primary); } + +.sov-factors { + list-style: none; + padding: 0; + margin: 0; +} + +.sov-factors li { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 9px; + line-height: 1.7; + color: var(--color-text-muted); +} + +.sov-factors li.active { + color: var(--color-text); +} + +.sov-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-text-muted); + flex-shrink: 0; + margin-left: 6px; +} + +.sov-dot.on { background: var(--color-primary); } +.sov-dot.warn { background: #ffaa00; } +.sov-dot.off { background: #ff4444; } + +@keyframes sov-idle { + 0%, 100% { border-color: var(--color-secondary); } + 50% { border-color: #2255aa; } +} -- 2.43.0