diff --git a/ui/event_log_panel.css b/ui/event_log_panel.css
new file mode 100644
index 0000000..efb532f
--- /dev/null
+++ b/ui/event_log_panel.css
@@ -0,0 +1,209 @@
+/* event_log_panel.css — Saltybot Event Log Panel (Issue #576) */
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --bg0: #050510;
+ --bg1: #070712;
+ --bg2: #0a0a1a;
+ --bd: #0c2a3a;
+ --bd2: #1e3a5f;
+ --dim: #374151;
+ --mid: #6b7280;
+ --base: #9ca3af;
+ --hi: #d1d5db;
+ --cyan: #06b6d4;
+ --green: #22c55e;
+ --amber: #f59e0b;
+ --red: #ef4444;
+ --purple: #a855f7;
+}
+
+body {
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 12px;
+ background: var(--bg0);
+ color: var(--base);
+ height: 100dvh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* ── Header ── */
+#header {
+ display: flex;
+ align-items: center;
+ padding: 6px 12px;
+ background: var(--bg1);
+ border-bottom: 1px solid var(--bd);
+ flex-shrink: 0;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.logo { color: #f97316; font-weight: bold; letter-spacing: 0.15em; font-size: 13px; flex-shrink: 0; }
+
+#conn-dot {
+ width: 8px; height: 8px; border-radius: 50%;
+ background: var(--dim); flex-shrink: 0; transition: background 0.3s;
+}
+#conn-dot.connected { background: var(--green); }
+#conn-dot.error { background: var(--red); animation: blink 1s infinite; }
+
+@keyframes blink { 0%,100%{opacity:1}50%{opacity:.3} }
+
+#ws-input {
+ background: var(--bg2); border: 1px solid var(--bd2); border-radius: 4px;
+ color: #67e8f9; padding: 2px 7px; font-family: monospace; font-size: 11px; width: 190px;
+}
+#ws-input:focus { outline: none; border-color: var(--cyan); }
+
+.hbtn {
+ padding: 2px 8px; border-radius: 4px; border: 1px solid var(--bd2);
+ background: var(--bg2); color: #67e8f9; font-family: monospace;
+ font-size: 10px; font-weight: bold; cursor: pointer; transition: background .15s; white-space: nowrap;
+}
+.hbtn:hover { background: #0e4f69; }
+.hbtn.active { background: #0e4f69; border-color: var(--cyan); }
+.hbtn.pause { background: #451a03; border-color: #92400e; color: #fcd34d; }
+.hbtn.pause:hover { background: #6b2b04; }
+
+#count-badge {
+ font-size: 10px; color: var(--mid); white-space: nowrap; margin-left: auto;
+}
+#paused-indicator {
+ font-size: 10px; color: #fcd34d; display: none; animation: blink 1.5s infinite;
+}
+#paused-indicator.visible { display: inline; }
+
+/* ── Toolbar ── */
+#toolbar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 12px;
+ background: var(--bg1);
+ border-bottom: 1px solid var(--bd);
+ flex-shrink: 0;
+ flex-wrap: wrap;
+}
+
+/* Severity filter buttons */
+.sev-btn {
+ padding: 2px 7px; border-radius: 3px; border: 1px solid;
+ font-family: monospace; font-size: 9px; font-weight: bold;
+ cursor: pointer; transition: opacity .15s, filter .15s; letter-spacing: 0.05em;
+}
+.sev-btn.off { opacity: 0.25; filter: grayscale(0.6); }
+
+.sev-debug { background: #1a1a2e; border-color: #4b5563; color: #9ca3af; }
+.sev-info { background: #032a1e; border-color: #065f46; color: #34d399; }
+.sev-warn { background: #3d1a00; border-color: #92400e; color: #fbbf24; }
+.sev-error { background: #3d0000; border-color: #991b1b; color: #f87171; }
+.sev-fatal { background: #2e003d; border-color: #7e22ce; color: #c084fc; }
+.sev-event { background: #001a3d; border-color: #1d4ed8; color: #60a5fa; }
+
+/* Search input */
+#search-input {
+ background: var(--bg2); border: 1px solid var(--bd2); border-radius: 4px;
+ color: var(--hi); padding: 2px 7px; font-family: monospace; font-size: 11px;
+ width: 160px;
+}
+#search-input:focus { outline: none; border-color: var(--cyan); }
+
+#node-filter {
+ background: var(--bg2); border: 1px solid var(--bd2); border-radius: 4px;
+ color: var(--base); padding: 2px 5px; font-family: monospace; font-size: 10px;
+ max-width: 140px;
+}
+#node-filter:focus { outline: none; border-color: var(--cyan); }
+
+.toolbar-sep { width: 1px; height: 16px; background: var(--bd2); flex-shrink: 0; }
+
+/* ── Main layout ── */
+#main {
+ flex: 1;
+ display: flex;
+ min-height: 0;
+}
+
+/* ── Log feed ── */
+#log-feed {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 4px 0;
+ scrollbar-width: thin;
+ scrollbar-color: var(--bd2) var(--bg0);
+}
+#log-feed::-webkit-scrollbar { width: 6px; }
+#log-feed::-webkit-scrollbar-track { background: var(--bg0); }
+#log-feed::-webkit-scrollbar-thumb { background: var(--bd2); border-radius: 3px; }
+
+/* ── Log entry ── */
+.log-entry {
+ display: grid;
+ grid-template-columns: 80px 54px minmax(80px,160px) 1fr;
+ gap: 0 8px;
+ align-items: baseline;
+ padding: 2px 12px;
+ border-bottom: 1px solid transparent;
+ transition: background 0.1s;
+ cursor: default;
+}
+.log-entry:hover { background: rgba(255,255,255,0.025); border-bottom-color: var(--bd); }
+
+.log-entry.sev-debug { border-left: 2px solid #4b5563; }
+.log-entry.sev-info { border-left: 2px solid #065f46; }
+.log-entry.sev-warn { border-left: 2px solid #92400e; }
+.log-entry.sev-error { border-left: 2px solid #991b1b; }
+.log-entry.sev-fatal { border-left: 2px solid #7e22ce; }
+.log-entry.sev-event { border-left: 2px solid #1d4ed8; }
+
+.log-ts { color: var(--mid); font-size: 10px; white-space: nowrap; }
+.log-sev { font-size: 9px; font-weight: bold; letter-spacing: 0.05em; white-space: nowrap; }
+.log-node { color: var(--mid); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.log-msg { color: var(--hi); font-size: 11px; word-break: break-word; white-space: pre-wrap; }
+
+.log-entry.sev-debug .log-sev { color: #9ca3af; }
+.log-entry.sev-info .log-sev { color: #34d399; }
+.log-entry.sev-warn .log-sev { color: #fbbf24; }
+.log-entry.sev-error .log-sev { color: #f87171; }
+.log-entry.sev-fatal .log-sev { color: #c084fc; }
+.log-entry.sev-event .log-sev { color: #60a5fa; }
+
+.log-entry.sev-error .log-msg { color: #fca5a5; }
+.log-entry.sev-fatal .log-msg { color: #e9d5ff; }
+.log-entry.sev-warn .log-msg { color: #fde68a; }
+
+/* Search highlight */
+mark {
+ background: rgba(234,179,8,0.35);
+ color: inherit;
+ border-radius: 2px;
+ padding: 0 1px;
+}
+
+/* Empty state */
+#empty-state {
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
+ height: 100%; color: var(--dim); gap: 8px;
+ user-select: none;
+}
+#empty-state .icon { font-size: 40px; }
+
+/* ── Footer ── */
+#footer {
+ background: var(--bg1); border-top: 1px solid var(--bd);
+ padding: 3px 12px;
+ display: flex; align-items: center; justify-content: space-between;
+ flex-shrink: 0; font-size: 10px; color: var(--dim);
+}
+
+/* ── Mobile ── */
+@media (max-width: 640px) {
+ .log-entry { grid-template-columns: 70px 44px 1fr; }
+ .log-node { display: none; }
+ #search-input { width: 100px; }
+ #node-filter { max-width: 90px; }
+}
diff --git a/ui/event_log_panel.html b/ui/event_log_panel.html
new file mode 100644
index 0000000..0f500b8
--- /dev/null
+++ b/ui/event_log_panel.html
@@ -0,0 +1,90 @@
+
+
+
+
+
+Saltybot — Event Log
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📋
+
No events — connect to rosbridge
+
+ Subscribing to /rosout and /saltybot/events
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/event_log_panel.js b/ui/event_log_panel.js
new file mode 100644
index 0000000..362b87b
--- /dev/null
+++ b/ui/event_log_panel.js
@@ -0,0 +1,386 @@
+/**
+ * event_log_panel.js — Saltybot Event Log Panel (Issue #576)
+ *
+ * Subscribes via rosbridge WebSocket to:
+ * /rosout rcl_interfaces/msg/Log — ROS2 node messages
+ * /saltybot/events std_msgs/String (JSON) — custom robot events
+ *
+ * Features:
+ * - 1000-entry ring buffer (oldest dropped when full)
+ * - Filter by severity (DEBUG/INFO/WARN/ERROR/FATAL + EVENTS)
+ * - Filter by node name (select from seen nodes)
+ * - Text search with highlight
+ * - Auto-scroll (pauses when user scrolls up, resumes at bottom)
+ * - Pause on hover (stops auto-scroll, doesn't drop messages)
+ * - CSV export of current filtered view
+ * - Clear all / clear filtered
+ */
+
+'use strict';
+
+// ── Constants ─────────────────────────────────────────────────────────────────
+
+const RING_CAPACITY = 1000;
+
+// ROS2 /rosout level byte values
+const ROS_LEVELS = {
+ 10: 'DEBUG',
+ 20: 'INFO',
+ 30: 'WARN',
+ 40: 'ERROR',
+ 50: 'FATAL',
+};
+
+const SEV_LABEL = {
+ DEBUG: 'DEBUG',
+ INFO: 'INFO ',
+ WARN: 'WARN ',
+ ERROR: 'ERROR',
+ FATAL: 'FATAL',
+ EVENT: 'EVENT',
+};
+
+// ── State ─────────────────────────────────────────────────────────────────────
+
+let ros = null;
+let rosoutSub = null;
+let eventsSub = null;
+
+// Ring buffer — array of entry objects
+const ring = [];
+let nextId = 0; // monotonic entry ID
+
+// Seen node names for filter dropdown
+const seenNodes = new Set();
+
+// Filter state
+const filters = {
+ levels: new Set(['DEBUG','INFO','WARN','ERROR','FATAL','EVENT']),
+ node: '', // '' = all
+ search: '',
+};
+
+// Auto-scroll + hover-pause
+let autoScroll = true;
+let hoverPaused = false;
+let userScrolled = false; // user scrolled away manually
+
+// Pending DOM rows to flush (batched for perf)
+let pendingFlush = false;
+
+// ── DOM refs ──────────────────────────────────────────────────────────────────
+
+const feed = document.getElementById('log-feed');
+const emptyState = document.getElementById('empty-state');
+const countBadge = document.getElementById('count-badge');
+const pausedInd = document.getElementById('paused-indicator');
+const searchEl = document.getElementById('search-input');
+const nodeFilter = document.getElementById('node-filter');
+
+// ── Utility ───────────────────────────────────────────────────────────────────
+
+function tsNow() {
+ return new Date().toLocaleTimeString('en-US', {
+ hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit',
+ }) + '.' + String(Date.now() % 1000).padStart(3,'0');
+}
+
+function tsFromRos(stamp) {
+ if (!stamp) return tsNow();
+ const ms = stamp.sec * 1000 + Math.floor((stamp.nanosec ?? 0) / 1e6);
+ const d = new Date(ms);
+ return d.toLocaleTimeString('en-US', {
+ hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit',
+ }) + '.' + String(d.getMilliseconds()).padStart(3,'0');
+}
+
+function escapeHtml(s) {
+ return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
+}
+
+function highlightSearch(text, term) {
+ if (!term) return escapeHtml(text);
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
+ const re = new RegExp(`(${escaped})`, 'gi');
+ return escapeHtml(text).replace(re, '$1');
+}
+
+// ── Ring buffer ───────────────────────────────────────────────────────────────
+
+function pushEntry(entry) {
+ entry.id = nextId++;
+ ring.push(entry);
+ if (ring.length > RING_CAPACITY) ring.shift();
+
+ if (!seenNodes.has(entry.node) && entry.node) {
+ seenNodes.add(entry.node);
+ rebuildNodeFilter();
+ }
+}
+
+// ── ROS connection ────────────────────────────────────────────────────────────
+
+function connect() {
+ const url = document.getElementById('ws-input').value.trim();
+ if (!url) return;
+ if (ros) ros.close();
+
+ ros = new ROSLIB.Ros({ url });
+
+ ros.on('connection', () => {
+ document.getElementById('conn-dot').className = 'connected';
+ document.getElementById('conn-label').textContent = url;
+ document.getElementById('btn-connect').textContent = 'RECONNECT';
+ setupSubs();
+ addSystemEntry('Connected to ' + url, 'INFO');
+ });
+
+ ros.on('error', (err) => {
+ document.getElementById('conn-dot').className = 'error';
+ document.getElementById('conn-label').textContent = 'ERROR: ' + (err?.message || err);
+ teardown();
+ addSystemEntry('Connection error: ' + (err?.message || err), 'ERROR');
+ });
+
+ ros.on('close', () => {
+ document.getElementById('conn-dot').className = '';
+ document.getElementById('conn-label').textContent = 'Disconnected';
+ teardown();
+ addSystemEntry('Disconnected', 'WARN');
+ });
+}
+
+function setupSubs() {
+ // /rosout — ROS2 log messages
+ rosoutSub = new ROSLIB.Topic({
+ ros, name: '/rosout',
+ messageType: 'rcl_interfaces/msg/Log',
+ });
+ rosoutSub.subscribe((msg) => {
+ const level = ROS_LEVELS[msg.level] ?? 'INFO';
+ pushEntry({
+ ts: tsFromRos(msg.stamp),
+ level,
+ node: (msg.name || 'unknown').replace(/^\//, ''),
+ msg: msg.msg || '',
+ source: 'rosout',
+ });
+ scheduleRender();
+ });
+
+ // /saltybot/events — custom events (JSON string: {level, node, msg})
+ eventsSub = new ROSLIB.Topic({
+ ros, name: '/saltybot/events',
+ messageType: 'std_msgs/String',
+ });
+ eventsSub.subscribe((msg) => {
+ let level = 'EVENT', node = 'saltybot', text = msg.data;
+ try {
+ const parsed = JSON.parse(msg.data);
+ level = (parsed.level || 'EVENT').toUpperCase();
+ node = parsed.node || 'saltybot';
+ text = parsed.msg || parsed.message || msg.data;
+ } catch (_) { /* raw string */ }
+
+ pushEntry({ ts: tsNow(), level, node, msg: text, source: 'events' });
+ scheduleRender();
+ });
+}
+
+function teardown() {
+ if (rosoutSub) { rosoutSub.unsubscribe(); rosoutSub = null; }
+ if (eventsSub) { eventsSub.unsubscribe(); eventsSub = null; }
+}
+
+function addSystemEntry(text, level = 'INFO') {
+ pushEntry({ ts: tsNow(), level, node: '[system]', msg: text, source: 'system' });
+ scheduleRender();
+}
+
+// ── Rendering ─────────────────────────────────────────────────────────────────
+
+function scheduleRender() {
+ if (pendingFlush) return;
+ pendingFlush = true;
+ requestAnimationFrame(renderAll);
+}
+
+function entryVisible(e) {
+ if (!filters.levels.has(e.level)) return false;
+ if (filters.node && e.node !== filters.node) return false;
+ if (filters.search) {
+ const q = filters.search.toLowerCase();
+ if (!e.msg.toLowerCase().includes(q) &&
+ !e.node.toLowerCase().includes(q)) return false;
+ }
+ return true;
+}
+
+function buildRow(e) {
+ const cls = 'sev-' + e.level.toLowerCase();
+ const hl = highlightSearch(e.msg, filters.search);
+ return `` +
+ `${e.ts}` +
+ `${SEV_LABEL[e.level] ?? e.level}` +
+ `${escapeHtml(e.node)}` +
+ `${hl}` +
+ `
`;
+}
+
+function renderAll() {
+ pendingFlush = false;
+
+ const visible = ring.filter(entryVisible);
+
+ if (visible.length === 0) {
+ feed.innerHTML = '';
+ emptyState.style.display = 'flex';
+ countBadge.textContent = `0 / ${ring.length}`;
+ return;
+ }
+
+ emptyState.style.display = 'none';
+ feed.innerHTML = visible.map(buildRow).join('');
+ countBadge.textContent = `${visible.length} / ${ring.length}`;
+
+ maybeScrollBottom();
+}
+
+function maybeScrollBottom() {
+ if ((autoScroll && !hoverPaused && !userScrolled)) {
+ feed.scrollTop = feed.scrollHeight;
+ }
+}
+
+// ── Auto-scroll + pause logic ─────────────────────────────────────────────────
+
+feed.addEventListener('mouseenter', () => {
+ hoverPaused = true;
+ pausedInd.classList.add('visible');
+});
+feed.addEventListener('mouseleave', () => {
+ hoverPaused = false;
+ pausedInd.classList.remove('visible');
+ if (autoScroll && !userScrolled) feed.scrollTop = feed.scrollHeight;
+});
+
+feed.addEventListener('scroll', () => {
+ const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 40;
+ if (atBottom) {
+ userScrolled = false;
+ } else if (!hoverPaused) {
+ userScrolled = true;
+ }
+});
+
+// Pause/resume button
+document.getElementById('btn-pause').addEventListener('click', () => {
+ autoScroll = !autoScroll;
+ userScrolled = false;
+ const btn = document.getElementById('btn-pause');
+ if (autoScroll) {
+ btn.textContent = '⏸ PAUSE';
+ btn.classList.remove('pause');
+ btn.classList.add('active');
+ feed.scrollTop = feed.scrollHeight;
+ } else {
+ btn.textContent = '▶ RESUME';
+ btn.classList.add('pause');
+ btn.classList.remove('active');
+ }
+});
+
+// ── Filter controls ───────────────────────────────────────────────────────────
+
+document.querySelectorAll('.sev-btn').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const lv = btn.dataset.level;
+ if (filters.levels.has(lv)) {
+ filters.levels.delete(lv);
+ btn.classList.add('off');
+ } else {
+ filters.levels.add(lv);
+ btn.classList.remove('off');
+ }
+ scheduleRender();
+ });
+});
+
+searchEl.addEventListener('input', () => {
+ filters.search = searchEl.value.trim();
+ scheduleRender();
+});
+
+nodeFilter.addEventListener('change', () => {
+ filters.node = nodeFilter.value;
+ scheduleRender();
+});
+
+function rebuildNodeFilter() {
+ const current = nodeFilter.value;
+ const nodes = [...seenNodes].sort();
+ nodeFilter.innerHTML = '' +
+ nodes.map(n => ``).join('');
+}
+
+// ── Clear ─────────────────────────────────────────────────────────────────────
+
+document.getElementById('btn-clear').addEventListener('click', () => {
+ ring.length = 0;
+ seenNodes.clear();
+ rebuildNodeFilter();
+ scheduleRender();
+});
+
+// ── CSV export ────────────────────────────────────────────────────────────────
+
+document.getElementById('btn-export').addEventListener('click', () => {
+ const visible = ring.filter(entryVisible);
+ if (visible.length === 0) return;
+
+ const header = 'timestamp,level,node,message\n';
+ const rows = visible.map((e) =>
+ [e.ts, e.level, e.node, '"' + e.msg.replace(/"/g,'""') + '"'].join(',')
+ );
+ const csv = header + rows.join('\n');
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `saltybot_log_${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+});
+
+// ── Connect button ────────────────────────────────────────────────────────────
+
+document.getElementById('btn-connect').addEventListener('click', connect);
+document.getElementById('ws-input').addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') connect();
+});
+
+// ── Init ──────────────────────────────────────────────────────────────────────
+
+const stored = localStorage.getItem('evlog_ws_url');
+if (stored) document.getElementById('ws-input').value = stored;
+document.getElementById('ws-input').addEventListener('change', (e) => {
+ localStorage.setItem('evlog_ws_url', e.target.value);
+});
+
+// Keyboard shortcut: Ctrl+F focuses search
+document.addEventListener('keydown', (e) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
+ e.preventDefault();
+ searchEl.focus();
+ searchEl.select();
+ }
+ // Escape: clear search
+ if (e.key === 'Escape' && document.activeElement === searchEl) {
+ searchEl.value = '';
+ filters.search = '';
+ scheduleRender();
+ }
+});
+
+// Initial empty state
+scheduleRender();