feat(dashboard): restructure home page as status headline + 3 cards

Per plan.md §8e information architecture:
- index.html (109 lines) is now a home page with status headline,
  3 cards (People & Zones, Devices & Fleet Health, Recent Events),
  optional extras row, and mobile bottom nav
- live.html serves the full 3D viewer at /live route
- home-cards.js connects to /ws/dashboard for snapshot + incremental updates
- tokens.css provides the Radix dark design system
- layout.css provides the CSS Grid app shell with responsive breakpoints
- home.css provides card grid, status banner, responsive mobile layout

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-24 09:06:33 -04:00
parent 756faec80f
commit 7162d4523e
6 changed files with 5431 additions and 4472 deletions

270
dashboard/css/home.css Normal file
View file

@ -0,0 +1,270 @@
/*
Spaxel Home Page Status headline + 3 cards (§8e)
*/
.home-body {
font-family: var(--font-body);
background: var(--bg-page);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Top nav ── */
.home-nav {
background: var(--slate-3);
border-bottom: 1px solid var(--border-default);
padding: 0 var(--space-6);
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
}
.home-nav__logo {
font-size: var(--text-lg);
font-weight: var(--fw-heading);
color: var(--text-primary);
text-decoration: none;
display: flex;
align-items: center;
gap: var(--space-2);
}
.home-nav__links {
display: flex;
gap: var(--space-4);
}
.home-nav__link {
color: var(--text-secondary);
text-decoration: none;
font-size: var(--text-sm);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-control);
transition: background var(--transition-fast), color var(--transition-fast);
}
.home-nav__link:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.home-nav__link--active {
color: var(--color-primary);
}
/* ── Status headline (Row 1) ── */
.home-status {
padding: var(--space-6);
text-align: center;
}
.home-status__banner {
padding: var(--space-3) var(--space-6);
border-radius: var(--radius-card);
font-size: var(--text-base);
font-weight: var(--fw-heading);
line-height: var(--lh-body);
max-width: 700px;
margin: 0 auto;
}
.home-status__banner--ok {
background: rgba(70, 167, 88, 0.15);
color: var(--ok);
border: 1px solid rgba(70, 167, 88, 0.25);
}
.home-status__banner--warn {
background: rgba(229, 160, 13, 0.15);
color: var(--warn);
border: 1px solid rgba(229, 160, 13, 0.25);
}
.home-status__banner--alert {
background: rgba(229, 72, 77, 0.15);
color: var(--alert);
border: 1px solid rgba(229, 72, 77, 0.25);
}
/* ── Card grid (Row 2) ── */
.home-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-6);
padding: 0 var(--space-6) var(--space-6);
max-width: 1440px;
margin: 0 auto;
width: 100%;
}
/* ── Card ── */
.home-card {
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-4);
text-decoration: none;
color: inherit;
transition: border-color var(--transition-fast), background var(--transition-fast);
cursor: pointer;
}
.home-card:hover {
border-color: var(--border-strong);
background: var(--bg-hover);
}
.home-card__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.home-card__title {
font-size: var(--text-sm);
font-weight: var(--fw-heading);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.home-card__icon {
font-size: var(--text-lg);
color: var(--text-muted);
}
.home-card__count {
font-size: var(--text-2xl);
font-weight: var(--fw-heading);
line-height: var(--lh-heading);
}
.home-card__detail {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--lh-body);
min-height: 2.4em;
}
.home-card__meta {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.home-card__tag {
font-size: var(--text-xs);
padding: 2px var(--space-2);
border-radius: var(--radius-control);
background: var(--bg-active);
color: var(--text-secondary);
}
.home-card__tag--ok {
background: rgba(70, 167, 88, 0.15);
color: var(--ok);
}
.home-card__tag--warn {
background: rgba(229, 160, 13, 0.15);
color: var(--warn);
}
.home-card__tag--alert {
background: rgba(229, 72, 77, 0.15);
color: var(--alert);
}
/* ── Optional row (Row 3) ── */
.home-extras {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--space-6);
padding: 0 var(--space-6) var(--space-8);
max-width: 1440px;
margin: 0 auto;
width: 100%;
}
.home-extras__item {
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
padding: var(--space-4) var(--space-6);
font-size: var(--text-sm);
color: var(--text-secondary);
display: none;
}
.home-extras__item--visible {
display: block;
}
/* ── Mobile bottom nav ── */
.home-bottom-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--slate-3);
border-top: 1px solid var(--border-default);
padding-bottom: env(safe-area-inset-bottom);
z-index: 100;
}
.home-bottom-nav__list {
display: flex;
list-style: none;
}
.home-bottom-nav__item {
flex: 1;
text-align: center;
}
.home-bottom-nav__link {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: var(--space-2) 0;
color: var(--text-muted);
text-decoration: none;
font-size: var(--text-xs);
min-height: 44px;
justify-content: center;
}
.home-bottom-nav__link--active {
color: var(--color-primary);
}
/* ── Connection indicator ── */
.home-status__dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
vertical-align: middle;
margin-right: var(--space-2);
}
.home-status__dot--connected {
background: var(--ok);
box-shadow: 0 0 6px var(--ok);
}
.home-status__dot--disconnected {
background: var(--alert);
}
/* ── Responsive: tablet (single column cards) ── */
@media (max-width: 1023px) {
.home-cards {
grid-template-columns: 1fr;
max-width: 600px;
}
.home-nav__links {
display: none;
}
}
/* ── Responsive: mobile (bottom nav) ── */
@media (max-width: 639px) {
.home-body {
padding-bottom: 60px;
}
.home-bottom-nav {
display: block;
}
.home-status {
padding: var(--space-4);
}
.home-cards {
padding: 0 var(--space-4) var(--space-4);
gap: var(--space-4);
}
.home-extras {
padding: 0 var(--space-4) var(--space-4);
}
}

239
dashboard/css/layout.css Normal file
View file

@ -0,0 +1,239 @@
/*
Spaxel App Shell Page-level CSS Grid (§8e Layout)
Establishes one grid container per page. No absolute-positioned
layout containers overlays use <dialog> or fixed slide-ins.
*/
/* ── App shell grid ── */
.app-shell {
display: grid;
grid-template-areas:
"header header"
"nav main"
"footer footer";
grid-template-columns: 260px 1fr;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
max-width: 1440px;
margin-inline: auto;
}
/* Named grid areas */
.app-shell > .app-header { grid-area: header; }
.app-shell > .app-nav { grid-area: nav; }
.app-shell > .app-main { grid-area: main; }
.app-shell > .app-footer { grid-area: footer; }
/* ── Full-width pages (no sidebar nav) ── */
.app-shell--full {
grid-template-areas:
"header"
"main"
"footer";
grid-template-columns: 1fr;
}
/* ── Live-view shell (3D canvas fills viewport) ── */
.app-shell--live {
display: grid;
grid-template-areas:
"header"
"main";
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
min-height: 100dvh;
max-width: none;
margin-inline: 0;
}
/* ── Tablet: collapse sidebar nav ── */
@media (max-width: 1023px) {
.app-shell {
grid-template-areas:
"header"
"main"
"footer";
grid-template-columns: 1fr;
}
.app-shell > .app-nav {
display: none;
}
}
/* ── Mobile: single column + bottom nav ── */
@media (max-width: 639px) {
.app-shell,
.app-shell--full,
.app-shell--live {
grid-template-areas:
"header"
"main";
grid-template-columns: 1fr;
}
.app-shell > .app-nav,
.app-shell > .app-footer {
display: none;
}
/* Bottom navigation for mobile */
.app-mobile-nav {
display: flex;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--slate-3);
border-top: 1px solid var(--border-default);
padding-bottom: env(safe-area-inset-bottom);
z-index: 100;
}
.app-mobile-nav__list {
display: flex;
list-style: none;
width: 100%;
margin: 0;
padding: 0;
}
.app-mobile-nav__item {
flex: 1;
text-align: center;
}
.app-mobile-nav__link {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: var(--space-2) 0;
color: var(--text-muted);
text-decoration: none;
font-size: var(--text-xs);
min-height: 44px;
justify-content: center;
}
.app-mobile-nav__link--active {
color: var(--color-primary);
}
}
/* Desktop: hide mobile bottom nav */
@media (min-width: 640px) {
.app-mobile-nav {
display: none;
}
}
/* ── Touch targets (all interactive elements) ── */
@media (max-width: 1023px) {
.app-shell button,
.app-shell--full button,
.app-shell--live button,
.app-shell a,
.app-shell--full a,
.app-shell--live a,
.app-shell input[type="checkbox"],
.app-shell--full input[type="checkbox"],
.app-shell--live input[type="checkbox"],
.app-shell select,
.app-shell--full select,
.app-shell--live select {
min-height: 44px;
min-width: 44px;
}
}
/* ── Shared header styling ── */
.app-header {
background: var(--slate-3);
border-bottom: 1px solid var(--border-default);
padding: 0 var(--space-6);
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 100;
}
.app-header__logo {
font-size: var(--text-lg);
font-weight: var(--fw-heading);
color: var(--text-primary);
text-decoration: none;
display: flex;
align-items: center;
gap: var(--space-2);
}
.app-header__links {
display: flex;
gap: var(--space-4);
}
.app-header__link {
color: var(--text-secondary);
text-decoration: none;
font-size: var(--text-sm);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-control);
transition: background var(--transition-fast), color var(--transition-fast);
}
.app-header__link:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.app-header__link--active {
color: var(--color-primary);
}
@media (max-width: 1023px) {
.app-header__links {
display: none;
}
}
/* ── Shared sidebar nav ── */
.app-nav {
background: var(--slate-2);
border-right: 1px solid var(--border-default);
padding: var(--space-4);
overflow-y: auto;
}
/* ── Main content area ── */
.app-main {
overflow-y: auto;
overflow-x: hidden;
}
/* ── Dialog-based modals (replaces fixed-position overlays) ── */
dialog.app-dialog {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border-default);
border-radius: var(--radius-modal);
padding: 0;
max-width: 560px;
width: 92%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
dialog.app-dialog::backdrop {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
}
/* ── Mobile body padding for bottom nav ── */
@media (max-width: 639px) {
.has-mobile-nav {
padding-bottom: calc(60px + env(safe-area-inset-bottom));
}
}

111
dashboard/css/tokens.css Normal file
View file

@ -0,0 +1,111 @@
/*
Spaxel Design System Radix Dark Tokens (§8e)
Single source of truth for all dashboard surfaces.
*/
:root {
/* ── Radix Slate (dark) ── */
--slate-1: #111113;
--slate-2: #18191b;
--slate-3: #1e2024;
--slate-4: #262830;
--slate-5: #2e3039;
--slate-6: #363842;
--slate-7: #43454f;
--slate-8: #565867;
--slate-9: #6b6e7b;
--slate-10: #7c7f8c;
--slate-11: #9b9daa;
--slate-12: #ededf0;
/* ── Radix Blue (accent) ── */
--blue-1: #11181e;
--blue-2: #152233;
--blue-3: #1a2e47;
--blue-4: #1e3a5f;
--blue-5: #224777;
--blue-6: #26558f;
--blue-7: #2e6aad;
--blue-8: #3579c5;
--blue-9: #3e96e8;
--blue-10: #4daaf5;
--blue-11: #68bdfa;
--blue-12: #aedcff;
/* ── Semantic state ── */
--ok: #46a758; /* green-9 */
--warn: #e5a00d; /* amber-9 */
--alert: #e5484d; /* red-9 */
/* ── Semantic aliases ── */
--color-success: var(--ok);
--color-warning: var(--warn);
--color-danger: var(--alert);
--color-primary: var(--blue-9);
--color-primary-hover: var(--blue-10);
/* ── State with opacity (for badges, backgrounds) ── */
--ok-muted: rgba(70, 167, 88, 0.2);
--ok-border: rgba(70, 167, 88, 0.3);
--warn-muted: rgba(229, 160, 13, 0.2);
--warn-border: rgba(229, 160, 13, 0.3);
--alert-muted: rgba(229, 72, 77, 0.2);
--alert-border: rgba(229, 72, 77, 0.3);
--blue-muted: rgba(62, 150, 232, 0.15);
--blue-border: rgba(62, 150, 232, 0.3);
/* ── Background steps ── */
--bg-page: var(--slate-2);
--bg-card: var(--slate-3);
--bg-panel: var(--slate-3);
--bg-hover: var(--slate-4);
--bg-active: var(--slate-5);
--bg-input: var(--slate-4);
/* ── Text ── */
--text-primary: var(--slate-12);
--text-secondary: var(--slate-11);
--text-muted: var(--slate-9);
--text-on-accent: #fff;
/* ── Border ── */
--border-default: rgba(255, 255, 255, 0.08);
--border-subtle: rgba(255, 255, 255, 0.04);
--border-strong: rgba(255, 255, 255, 0.15);
/* ── Typography ── */
--font-body: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
--font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 20px;
--text-2xl: 28px;
--lh-body: 1.5;
--lh-heading: 1.2;
--fw-body: 400;
--fw-heading: 600;
/* ── Spacing (4 px grid) ── */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* ── Radius ── */
--radius-control: 6px;
--radius-card: 10px;
--radius-modal: 16px;
/* ── Transitions ── */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
}

File diff suppressed because it is too large Load diff

231
dashboard/js/home-cards.js Normal file
View file

@ -0,0 +1,231 @@
/**
* home-cards.js Populates the Spaxel home page from /ws/dashboard snapshot.
*
* Connects to the dashboard WebSocket, reads the initial snapshot to fill all
* three cards, then applies incremental updates to refresh counts only.
*/
(function () {
'use strict';
// ── DOM refs ──
const $banner = document.getElementById('status-banner');
const $dot = document.getElementById('ws-dot');
const $peopleCnt = document.getElementById('people-count');
const $peopleDtl = document.getElementById('people-detail');
const $devicesCnt = document.getElementById('devices-count');
const $devicesDtl = document.getElementById('devices-detail');
const $devicesMeta= document.getElementById('devices-meta');
const $eventsDtl = document.getElementById('events-detail');
const $briefing = document.getElementById('briefing-card');
const $anomaly = document.getElementById('anomaly-banner');
const $security = document.getElementById('security-toggle');
// ── State ──
let ws = null;
let reconnectDelay = 1000;
let recentEvents = [];
// ── Helpers ──
function $(id) { return document.getElementById(id); }
function relativeTime(isoOrMs) {
const ts = typeof isoOrMs === 'number' ? isoOrMs : new Date(isoOrMs).getTime();
const diff = Date.now() - ts;
if (diff < 60000) return 'just now';
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
return Math.floor(diff / 86400000) + 'd ago';
}
function setBanner(level, html) {
$banner.className = 'home-status__banner home-status__banner--' + level;
$banner.innerHTML = '<span class="home-status__dot home-status__dot--connected" id="ws-dot"></span>' + html;
}
// ── Card updaters ──
function updatePeopleCard(snapshot) {
const blobs = snapshot.blobs || [];
const zones = snapshot.zones || [];
const peopleNames = blobs
.map(function (b) { return b.person; })
.filter(function (p) { return p && p !== 'Unknown'; });
const uniquePeople = [];
peopleNames.forEach(function (n) {
if (uniquePeople.indexOf(n) === -1) uniquePeople.push(n);
});
$peopleCnt.textContent = uniquePeople.length + ' people';
if (uniquePeople.length > 0) {
$peopleDtl.textContent = uniquePeople.join(', ');
} else {
var occupied = zones.filter(function (z) { return z.count > 0; });
$peopleDtl.textContent = occupied.length > 0
? occupied.map(function (z) { return z.name; }).join(', ')
: 'No one detected';
}
}
function updateDevicesCard(snapshot) {
var nodes = snapshot.nodes || [];
var online = nodes.filter(function (n) { return n.status === 'online'; });
var stale = nodes.filter(function (n) { return n.status === 'stale'; });
var offline= nodes.filter(function (n) { return n.status === 'offline'; });
$devicesCnt.textContent = online.length + '/' + nodes.length + ' online';
$devicesMeta.innerHTML = '';
if (offline.length > 0) {
$devicesDtl.textContent = offline.length + ' device' + (offline.length > 1 ? 's' : '') + ' offline';
addTag($devicesMeta, 'alert', offline.length + ' offline');
} else if (stale.length > 0) {
$devicesDtl.textContent = stale.length + ' device' + (stale.length > 1 ? 's' : '') + ' stale';
addTag($devicesMeta, 'warn', stale.length + ' stale');
} else {
$devicesDtl.textContent = nodes.length === 0
? 'No devices registered'
: 'All devices healthy';
}
var quality = snapshot.confidence;
if (typeof quality === 'number') {
var qLevel = quality >= 80 ? 'ok' : quality >= 60 ? 'warn' : 'alert';
addTag($devicesMeta, qLevel, quality + '% quality');
}
}
function addTag(container, level, text) {
var span = document.createElement('span');
span.className = 'home-card__tag home-card__tag--' + level;
span.textContent = text;
container.appendChild(span);
}
function updateEventsCard(snapshot) {
var evts = snapshot.events || [];
if (evts.length > 0) {
recentEvents = evts.slice(0, 5);
}
renderEvents();
}
function renderEvents() {
if (recentEvents.length === 0) {
$eventsDtl.textContent = 'No recent events';
return;
}
var lines = recentEvents.slice(0, 5).map(function (e) {
var label = e.person || e.zone || e.type || 'Event';
var time = e.timestamp_ms ? relativeTime(e.timestamp_ms) : '';
return label + (time ? ' — ' + time : '');
});
$eventsDtl.innerHTML = lines.join('<br>');
}
function updateExtras(snapshot) {
// Morning briefing
if (snapshot.briefing) {
$briefing.textContent = snapshot.briefing;
$briefing.classList.add('home-extras__item--visible');
}
// Security mode
if (snapshot.security_mode) {
$security.textContent = 'Security mode: ARMED';
$security.classList.add('home-extras__item--visible');
}
// Anomaly
if (snapshot.anomaly_active) {
$anomaly.textContent = 'Anomaly detected';
$anomaly.classList.add('home-extras__item--visible');
}
}
function updateBanner(snapshot) {
var nodes = snapshot.nodes || [];
var blobs = snapshot.blobs || [];
var offline = nodes.filter(function (n) { return n.status === 'offline'; });
var people = blobs.filter(function (b) { return b.person; });
if (offline.length > 0) {
setBanner('warn', offline.length + ' device' + (offline.length > 1 ? 's' : '') + ' offline');
} else if (people.length > 0) {
var names = [];
people.forEach(function (b) {
if (b.person && names.indexOf(b.person) === -1) names.push(b.person);
});
var online = nodes.filter(function (n) { return n.status === 'online'; });
setBanner('ok', 'All clear — ' + names.length + ' people home, ' +
online.length + ' devices online');
} else {
var onlineN = nodes.filter(function (n) { return n.status === 'online'; });
setBanner('ok', 'All clear — No one detected, ' + onlineN.length + ' devices online');
}
}
// ── Snapshot processing ──
function handleSnapshot(snapshot) {
updateBanner(snapshot);
updatePeopleCard(snapshot);
updateDevicesCard(snapshot);
updateEventsCard(snapshot);
updateExtras(snapshot);
}
function handleIncremental(msg) {
// Lightweight refresh on incremental updates
if (msg.blobs) updatePeopleCard(msg);
if (msg.nodes) { updateDevicesCard(msg); updateBanner(msg); }
if (msg.events && msg.events.length > 0) {
// Prepend new events, keep last 5
recentEvents = msg.events.concat(recentEvents).slice(0, 5);
renderEvents();
}
}
// ── WebSocket ──
function connect() {
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var url = proto + '//' + location.host + '/ws/dashboard';
ws = new WebSocket(url);
ws.onopen = function () {
reconnectDelay = 1000;
};
ws.onmessage = function (evt) {
var msg;
try { msg = JSON.parse(evt.data); } catch (e) { return; }
if (msg.type === 'snapshot') {
handleSnapshot(msg);
} else {
handleIncremental(msg);
}
};
ws.onclose = function () {
setBanner('warn', 'Reconnecting&hellip;');
setTimeout(function () {
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
connect();
}, reconnectDelay);
};
ws.onerror = function () {
ws.close();
};
}
// ── Boot ──
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', connect);
} else {
connect();
}
// Refresh relative timestamps every 30s
setInterval(renderEvents, 30000);
})();

4489
dashboard/live.html Normal file

File diff suppressed because it is too large Load diff