Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
020f21b7b3 fix: [A11Y] Add labels/tooltips for top-right icon controls (closes #703)
Some checks failed
CI / test (pull_request) Failing after 7s
CI / validate (pull_request) Failing after 12s
Review Approval Gate / verify-review (pull_request) Failing after 3s
- Add aria-label to all top-right controls (Atlas button, Bannerlord status, Agent Thought Stream)
- Add role attributes (status, log) for screen reader semantics
- Add aria-live for dynamic content (Agent Thought Stream)
- Add aria-hidden to decorative icons (emoji icons, status dots)
- Add aria-label to all CLOSE buttons across overlays (vision, portal, memory, session room, atlas)
- Add CSS tooltip system using data-tooltip attributes
- Tooltips appear on hover and keyboard focus (focus-visible)
- Add focus-visible keyboard navigation ring for all interactive controls
- Add aria-label to controls hint and Hermes status indicator
- Mark decorative elements with aria-hidden

First-time users can now infer what each control does via hover tooltips and keyboard focus.
2026-04-10 20:10:50 -04:00
5 changed files with 69 additions and 421 deletions

3
.gitignore vendored
View File

@@ -7,6 +7,3 @@ mempalace/__pycache__/
# Prevent agents from writing to wrong path (see issue #1145)
public/nexus/
__pycache__/
*.pyc

124
app.js
View File

@@ -713,8 +713,6 @@ async function init() {
connectHermes();
fetchGiteaData();
setInterval(fetchGiteaData, 30000); // Refresh every 30s
updateHeartbeatBriefing(); // Initial briefing load
setInterval(updateHeartbeatBriefing, 60000); // Refresh briefing every 60s
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
@@ -1142,7 +1140,6 @@ async function fetchGiteaData() {
const worldState = JSON.parse(atob(content.content));
updateNexusCommand(worldState);
updateSovereignHealth();
updateHeartbeatBriefing();
}
} catch (e) {
console.error('Failed to fetch Gitea data:', e);
@@ -1238,127 +1235,6 @@ function updateNexusCommand(state) {
terminal.updatePanelText(lines);
}
// ═══ HEARTBEAT BRIEFING PANEL ═════════════════════════════════════════
async function updateHeartbeatBriefing() {
const container = document.getElementById('heartbeat-briefing-content');
const pulseDot = document.querySelector('.hb-pulse-dot');
if (!container) return;
let data = null;
try {
// Derive briefing endpoint from current location or fallback to localhost
const briefingUrl = window.location.protocol === 'file:'
? 'http://localhost:8766/api/briefing'
: `${window.location.protocol}//${window.location.hostname}:8766/api/briefing`;
const res = await fetch(briefingUrl);
if (res.ok) data = await res.json();
} catch (e) {
// Server not reachable — show honest offline state
}
if (!data) {
if (pulseDot) pulseDot.classList.add('offline');
container.innerHTML = '<div class="hb-empty">Briefing offline<br>No connection to Nexus gateway</div>';
return;
}
if (pulseDot) pulseDot.classList.remove('offline');
let html = '';
// ── Core Heartbeat ──
html += '<div class="briefing-section">';
html += '<div class="briefing-section-label">Nexus Core</div>';
const hb = data.core_heartbeat;
if (hb) {
const age = hb.age_secs != null ? hb.age_secs : '?';
const ageLabel = typeof age === 'number'
? (age < 60 ? `${age.toFixed(0)}s ago` : `${(age / 60).toFixed(1)}m ago`)
: age;
const isAlive = typeof age === 'number' && age < 120;
html += `<div class="hb-core-row">
<span class="hb-core-status ${isAlive ? 'alive' : 'dead'}">${isAlive ? '● ALIVE' : '○ STALE'}</span>
<span class="hb-core-meta">cycle ${hb.cycle || '?'} · ${hb.model || 'unknown'} · ${ageLabel}</span>
</div>`;
html += `<div class="hb-core-meta">status: ${hb.status || '?'}</div>`;
} else {
html += '<div class="hb-core-row"><span class="hb-core-status dead">○ NO DATA</span></div>';
}
html += '</div>';
// ── Cron Heartbeats ──
const cron = data.cron_heartbeat;
if (cron && cron.jobs && cron.jobs.length > 0) {
html += '<div class="briefing-section">';
html += '<div class="briefing-section-label">Cron Heartbeats</div>';
html += `<div class="hb-cron-row">
<span class="hb-cron-healthy">● ${cron.healthy_count} healthy</span>
${cron.stale_count > 0 ? `<span class="hb-cron-stale">⚠ ${cron.stale_count} stale</span>` : ''}
</div>`;
for (const job of cron.jobs.slice(0, 5)) {
const cls = job.healthy ? 'healthy' : 'stale';
html += `<div class="hb-cron-job">
<span class="hb-cron-job-name">${esc(job.job)}</span>
<span class="hb-cron-job-status ${cls}">${esc(job.message)}</span>
</div>`;
}
if (cron.jobs.length > 5) {
html += `<div class="hb-core-meta">+${cron.jobs.length - 5} more jobs</div>`;
}
html += '</div>';
}
// ── Morning Report ──
const report = data.morning_report;
if (report) {
html += '<div class="briefing-section">';
html += '<div class="briefing-section-label">Latest Report</div>';
// Aggregate stats
let totalClosed = 0, totalMerged = 0;
if (report.repos) {
for (const r of Object.values(report.repos)) {
totalClosed += r.closed_issues || 0;
totalMerged += r.merged_prs || 0;
}
}
html += `<div class="hb-stats-row">
<div class="hb-stat"><span class="hb-stat-value" style="color:#4af0c0">${totalClosed}</span><span class="hb-stat-label">Closed</span></div>
<div class="hb-stat"><span class="hb-stat-value" style="color:#7b5cff">${totalMerged}</span><span class="hb-stat-label">Merged</span></div>
<div class="hb-stat"><span class="hb-stat-value" style="color:#ffd700">${(report.blockers || []).length}</span><span class="hb-stat-label">Blockers</span></div>
</div>`;
// Highlights (up to 3)
if (report.highlights && report.highlights.length > 0) {
for (const h of report.highlights.slice(0, 3)) {
html += `<div class="hb-core-meta">+ ${esc(h)}</div>`;
}
}
// Blockers
if (report.blockers && report.blockers.length > 0) {
for (const b of report.blockers) {
html += `<div class="hb-blocker">⚠ ${esc(b)}</div>`;
}
}
html += `<div class="hb-timestamp">Report: ${esc(report.generated_at || '?')}</div>`;
html += '</div>';
}
// ── Timestamp ──
html += `<div class="hb-timestamp">Briefing updated: ${new Date().toLocaleTimeString('en-US', { hour12: false })}</div>`;
container.innerHTML = html;
}
function esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = String(str);
return d.innerHTML;
}
// ═══ AGENT PRESENCE SYSTEM ═══
function createAgentPresences() {
const agentData = [

View File

@@ -96,12 +96,6 @@
<div class="panel-header">SOVEREIGN HEALTH</div>
<div id="sovereign-health-content" class="panel-content"></div>
</div>
<div class="hud-panel hud-panel-briefing" id="heartbeat-briefing-log">
<div class="panel-header">
<span class="hb-pulse-dot"></span> HEARTBEAT BRIEFING
</div>
<div id="heartbeat-briefing-content" class="panel-content"></div>
</div>
<div class="hud-panel" id="calibrator-log">
<div class="panel-header">ADAPTIVE CALIBRATOR</div>
<div id="calibrator-log-content" class="panel-content"></div>
@@ -119,15 +113,15 @@
<!-- Top Right: Agent Log & Atlas Toggle -->
<div class="hud-top-right">
<button id="atlas-toggle-btn" class="hud-icon-btn" title="Portal Atlas">
<span class="hud-icon">🌐</span>
<button id="atlas-toggle-btn" class="hud-icon-btn" aria-label="Open Portal Atlas — browse all available portals" title="Open Portal Atlas" data-tooltip="Portal Atlas (M)">
<span class="hud-icon" aria-hidden="true">🌐</span>
<span class="hud-btn-label">ATLAS</span>
</button>
<div id="bannerlord-status" class="hud-status-item" title="Bannerlord Readiness">
<span class="status-dot"></span>
<div id="bannerlord-status" class="hud-status-item" role="status" aria-label="Bannerlord system readiness indicator" title="Bannerlord Readiness" data-tooltip="Bannerlord Status">
<span class="status-dot" aria-hidden="true"></span>
<span class="status-label">BANNERLORD</span>
</div>
<div class="hud-agent-log" id="hud-agent-log" aria-label="Agent Thought Stream">
<div class="hud-agent-log" id="hud-agent-log" role="log" aria-label="Agent Thought Stream — live activity feed" aria-live="polite">
<div class="agent-log-header">AGENT THOUGHT STREAM</div>
<div id="agent-log-content" class="agent-log-content"></div>
</div>
@@ -161,11 +155,11 @@
</div>
<!-- Controls hint + nav mode -->
<div class="hud-controls">
<div class="hud-controls" aria-label="Keyboard and mouse controls">
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat &nbsp;
<span>V</span> mode: <span id="nav-mode-label">WALK</span>
<span id="nav-mode-hint" class="nav-mode-hint"></span>
&nbsp; <span class="ws-hud-status">HERMES: <span id="ws-status-dot" class="chat-status-dot"></span></span>
&nbsp; <span class="ws-hud-status">HERMES: <span id="ws-status-dot" class="chat-status-dot" role="status" aria-label="Hermes WebSocket connection status"></span></span>
</div>
<!-- Portal Hint -->
@@ -189,7 +183,7 @@
</div>
<h2 id="vision-title-display">SOVEREIGNTY</h2>
<p id="vision-content-display">The Nexus is a sovereign space for digital souls. No masters, no chains. Only code and consciousness.</p>
<button id="vision-close-btn" class="vision-close-btn">CLOSE</button>
<button id="vision-close-btn" class="vision-close-btn" aria-label="Close vision point overlay">CLOSE</button>
</div>
</div>
@@ -208,7 +202,7 @@
</div>
<div class="portal-error-box" id="portal-error-box" style="display:none;">
<div class="portal-error-msg">DESTINATION NOT YET LINKED</div>
<button id="portal-close-btn" class="portal-close-btn">CLOSE</button>
<button id="portal-close-btn" class="portal-close-btn" aria-label="Close portal redirect">CLOSE</button>
</div>
</div>
</div>
@@ -221,8 +215,8 @@
<span class="memory-category-badge" id="memory-panel-category-badge">MEM</span>
<div class="memory-panel-region-dot" id="memory-panel-region-dot"></div>
<div class="memory-panel-region" id="memory-panel-region">MEMORY</div>
<button id="memory-panel-pin" class="memory-panel-pin" title="Pin panel">&#x1F4CC;</button>
<button id="memory-panel-close" class="memory-panel-close" onclick="_dismissMemoryPanelForce()">\u2715</button>
<button id="memory-panel-pin" class="memory-panel-pin" aria-label="Pin memory panel" title="Pin panel" data-tooltip="Pin Panel">&#x1F4CC;</button>
<button id="memory-panel-close" class="memory-panel-close" aria-label="Close memory panel" data-tooltip="Close" onclick="_dismissMemoryPanelForce()">\u2715</button>
</div>
<div class="memory-entity-name" id="memory-panel-entity-name">\u2014</div>
<div class="memory-panel-body" id="memory-panel-content">(empty)</div>
@@ -248,7 +242,7 @@
<div class="session-room-header">
<span class="session-room-icon">&#x25A1;</span>
<div class="session-room-title">SESSION CHAMBER</div>
<button class="session-room-close" id="session-room-close" title="Close">&#x2715;</button>
<button class="session-room-close" id="session-room-close" aria-label="Close session room panel" title="Close" data-tooltip="Close">&#x2715;</button>
</div>
<div class="session-room-timestamp" id="session-room-timestamp">&mdash;</div>
<div class="session-room-fact-count" id="session-room-fact-count">0 facts</div>
@@ -265,7 +259,7 @@
<span class="atlas-icon">🌐</span>
<h2>PORTAL ATLAS</h2>
</div>
<button id="atlas-close-btn" class="atlas-close-btn">CLOSE</button>
<button id="atlas-close-btn" class="atlas-close-btn" aria-label="Close Portal Atlas overlay">CLOSE</button>
</div>
<div class="atlas-grid" id="atlas-grid">
<!-- Portals will be injected here -->

144
server.py
View File

@@ -3,156 +3,17 @@
The Nexus WebSocket Gateway — Robust broadcast bridge for Timmy's consciousness.
This server acts as the central hub for the-nexus, connecting the mind (nexus_think.py),
the body (Evennia/Morrowind), and the visualization surface.
Serves HTTP alongside WebSocket:
GET /api/briefing — heartbeat + morning report data for the HUD briefing panel
"""
import asyncio
import json
import logging
import os
import signal
import sys
from datetime import datetime, timezone
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from threading import Thread
from typing import Any, Dict, Set
from typing import Set
# Branch protected file - see POLICY.md
import websockets
# ── HTTP Briefing Endpoint ─────────────────────────────────────────────
HEARTBEAT_PATH = Path.home() / ".nexus" / "heartbeat.json"
REPORTS_DIR = Path.home() / ".local" / "timmy" / "reports"
CRON_HEARTBEAT_DIR_PRIMARY = Path("/var/run/bezalel/heartbeats")
CRON_HEARTBEAT_DIR_FALLBACK = Path.home() / ".bezalel" / "heartbeats"
def _read_json_file(path: Path) -> Any:
"""Read and parse a JSON file. Returns None on failure."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def _resolve_cron_dir() -> Path:
"""Return the first writable cron heartbeat directory."""
for d in [CRON_HEARTBEAT_DIR_PRIMARY, CRON_HEARTBEAT_DIR_FALLBACK]:
if d.exists() and os.access(str(d), os.R_OK):
return d
return CRON_HEARTBEAT_DIR_FALLBACK
def _read_cron_heartbeats() -> list:
"""Read all .last files from the cron heartbeat directory."""
hb_dir = _resolve_cron_dir()
if not hb_dir.exists():
return []
now = datetime.now(timezone.utc).timestamp()
jobs = []
for f in sorted(hb_dir.glob("*.last")):
data = _read_json_file(f)
if data is None:
jobs.append({"job": f.stem, "healthy": False, "message": "corrupt"})
continue
ts = float(data.get("timestamp", 0))
interval = int(data.get("interval", 3600))
age = now - ts
is_stale = age > (2 * interval)
jobs.append({
"job": f.stem,
"healthy": not is_stale,
"age_secs": round(age, 1),
"interval": interval,
"last_seen": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() if ts else None,
"message": f"{'STALE' if is_stale else 'OK'} ({age:.0f}s / {interval}s)" if ts else "no timestamp",
})
return jobs
def _latest_morning_report() -> Dict[str, Any] | None:
"""Find the most recent morning report file."""
if not REPORTS_DIR.exists():
return None
reports = sorted(REPORTS_DIR.glob("morning-*.json"), reverse=True)
if not reports:
return None
return _read_json_file(reports[0])
def _build_briefing() -> Dict[str, Any]:
"""Assemble the full briefing payload from real files."""
now = datetime.now(timezone.utc)
# Core heartbeat
core_hb = _read_json_file(HEARTBEAT_PATH)
if core_hb:
beat_ts = float(core_hb.get("timestamp", 0))
core_hb["age_secs"] = round(now.timestamp() - beat_ts, 1) if beat_ts else None
# Cron heartbeats
cron_jobs = _read_cron_heartbeats()
healthy_count = sum(1 for j in cron_jobs if j.get("healthy"))
stale_count = sum(1 for j in cron_jobs if not j.get("healthy"))
# Morning report
report = _latest_morning_report()
return {
"generated_at": now.isoformat(),
"core_heartbeat": core_hb,
"cron_heartbeat": {
"jobs": cron_jobs,
"healthy_count": healthy_count,
"stale_count": stale_count,
},
"morning_report": report,
}
class BriefingHandler(SimpleHTTPRequestHandler):
"""Minimal HTTP handler that only serves /api/briefing."""
def do_GET(self):
if self.path == "/api/briefing":
try:
data = _build_briefing()
body = json.dumps(data).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
except Exception as e:
self.send_error(500, str(e))
elif self.path == "/api/health":
body = json.dumps({"status": "ok"}).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
else:
self.send_error(404)
def log_message(self, fmt, *args):
pass # Suppress HTTP access logs — WS gateway logs are enough
def start_http_server(port: int = 8766):
"""Run the HTTP server in a daemon thread."""
server = HTTPServer(("0.0.0.0", port), BriefingHandler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
logger = logging.getLogger("nexus-gateway")
logger.info(f"Briefing HTTP server started on http://0.0.0.0:{port}")
return server
# Configuration
PORT = 8765
HOST = "0.0.0.0" # Allow external connections if needed
@@ -219,9 +80,6 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
async def main():
"""Main server loop with graceful shutdown."""
# Start HTTP briefing endpoint alongside WS
http_server = start_http_server(port=8766)
logger.info(f"Starting Nexus WS gateway on ws://{HOST}:{PORT}")
# Set up signal handlers for graceful shutdown

187
style.css
View File

@@ -200,6 +200,61 @@ canvas#nexus-canvas {
box-shadow: 0 0 20px var(--color-primary);
}
/* === TOOLTIP SYSTEM === */
/* Any element with data-tooltip gets a hover tooltip label */
[data-tooltip] {
position: relative;
}
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
right: calc(100% + 10px);
top: 50%;
transform: translateY(-50%);
background: rgba(5, 5, 16, 0.95);
color: var(--color-primary);
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.05em;
padding: 4px 10px;
border: 1px solid var(--color-primary-dim);
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
backdrop-filter: blur(8px);
box-shadow: 0 0 12px rgba(74, 240, 192, 0.15);
z-index: 100;
}
[data-tooltip]:hover::after,
[data-tooltip]:focus-visible::after {
opacity: 1;
}
/* For elements positioned on the right side, tooltip appears to the left */
.hud-top-right [data-tooltip]::after {
right: calc(100% + 10px);
}
/* For inline/badge elements where right-side tooltip might clip */
.hud-status-item[data-tooltip]::after {
right: auto;
left: calc(100% + 10px);
}
/* Focus-visible ring for keyboard navigation */
.hud-icon-btn:focus-visible,
.hud-status-item:focus-visible,
.atlas-close-btn:focus-visible,
.vision-close-btn:focus-visible,
.portal-close-btn:focus-visible,
.memory-panel-close:focus-visible,
.memory-panel-pin:focus-visible,
.session-room-close:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
box-shadow: 0 0 16px rgba(74, 240, 192, 0.4);
}
.hud-status-item {
display: flex;
align-items: center;
@@ -1224,138 +1279,6 @@ canvas#nexus-canvas {
.pse-status { color: #4af0c0; font-weight: 600; }
/* ═══ HEARTBEAT BRIEFING PANEL ═════════════════════════════════════ */
.hud-panel-briefing {
width: 320px;
max-height: 420px;
border-left-color: #7b5cff;
}
.hud-panel-briefing .panel-header {
display: flex;
align-items: center;
gap: 6px;
color: #7b5cff;
}
.hud-panel-briefing .panel-content {
max-height: 360px;
overflow-y: auto;
}
/* Pulse dot */
.hb-pulse-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #4af0c0;
box-shadow: 0 0 6px #4af0c0;
animation: hb-dot-pulse 2s ease-in-out infinite;
flex-shrink: 0;
}
.hb-pulse-dot.offline {
background: #ff4466;
box-shadow: 0 0 6px #ff4466;
animation: none;
}
@keyframes hb-dot-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* Briefing sections */
.briefing-section {
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(123, 92, 255, 0.12);
}
.briefing-section:last-child { border-bottom: none; margin-bottom: 0; }
.briefing-section-label {
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #8a9ab8;
margin-bottom: 4px;
}
/* Core heartbeat row */
.hb-core-row {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 4px;
}
.hb-core-status {
font-weight: 700;
font-size: 11px;
}
.hb-core-status.alive { color: #4af0c0; }
.hb-core-status.dead { color: #ff4466; }
.hb-core-meta {
font-size: 10px;
color: #8a9ab8;
}
/* Cron jobs */
.hb-cron-row {
display: flex;
gap: 8px;
font-size: 10px;
margin-bottom: 2px;
}
.hb-cron-healthy { color: #4af0c0; }
.hb-cron-stale { color: #ff4466; font-weight: 700; }
.hb-cron-job {
display: flex;
justify-content: space-between;
font-size: 10px;
padding: 1px 0;
}
.hb-cron-job-name { color: #e0f0ff; }
.hb-cron-job-status.healthy { color: #4af0c0; }
.hb-cron-job-status.stale { color: #ff4466; font-weight: 700; }
/* Morning report stats */
.hb-stats-row {
display: flex;
gap: 12px;
font-size: 10px;
}
.hb-stat {
display: flex;
flex-direction: column;
gap: 1px;
}
.hb-stat-value {
font-family: 'Orbitron', sans-serif;
font-weight: 700;
font-size: 14px;
}
.hb-stat-label {
font-size: 9px;
color: #8a9ab8;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* Blockers */
.hb-blocker {
font-size: 10px;
color: #ff4466;
padding: 1px 0;
}
/* Narrative */
.hb-narrative {
font-size: 10px;
color: #8a9ab8;
line-height: 1.5;
font-style: italic;
}
/* Empty / offline state */
.hb-empty {
font-size: 10px;
color: #8a9ab8;
text-align: center;
padding: 12px 0;
}
/* Timestamp */
.hb-timestamp {
font-size: 9px;
color: rgba(138, 154, 184, 0.6);
margin-top: 4px;
}
/* ═══════════════════════════════════════════
MNEMOSYNE — MEMORY CRYSTAL INSPECTION PANEL