Files
agentmon/docs/plans/2026-03-28-ui-ux-improvements-design.md
2026-04-21 13:07:05 -07:00

33 KiB

Web UI/UX Improvements — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Holistic UI/UX improvement pass across all pages — better navigation, richer data display, improved micro-interactions, and new features that leverage existing API data.

Architecture: Pure frontend changes to app.js and style.css. No backend modifications needed — all improvements use existing query-api endpoints and WebSocket data. The app is vanilla JS (no framework, no build tools), so all changes are direct edits.

Tech Stack: Vanilla JS, CSS3 custom properties, uPlot charting, WebSocket real-time.


Summary of Changes

Area Improvement Impact
Navigation Command palette (Cmd+K) High — fast access to any page/session/agent
Navigation Keyboard shortcuts (g+d, g+s, g+a, g+i) Medium — power user efficiency
Dashboard Sparkline mini-charts on summary cards High — at-a-glance trend visibility
Dashboard Animated counter transitions Medium — polished feel on live updates
Dashboard Error pulse effect when errors spike Medium — draws attention to problems
Sessions Status filter pills (All/Active/Ended/Errored) High — fast filtering without date pickers
Sessions Duration column with relative bar Medium — visual session comparison
Sessions Active session highlight strip Medium — immediately see what's running
Agents Activity sparkline per agent lane High — history at a glance per agent
Agents Idle timer showing "last active X ago" Medium — know when agents went quiet
Infrastructure Uptime percentage badges Medium — quick health assessment
Infrastructure Last-check countdown timers Low — shows data freshness
Cross-cutting Page transition animations (crossfade) Medium — polished navigation feel
Cross-cutting Error notification badge in header nav High — always-visible error awareness
Cross-cutting Auto-refresh relative timestamps Low — already partially done, needs consistency
Cross-cutting Better skeleton loading screens Low — content-aware shapes vs generic lines
Cross-cutting Sticky section headers Low — better scroll context

Task 1: Command Palette (Cmd+K)

Files:

  • Modify: cmd/web-ui/static/app.js (add ~120 lines near keyboard shortcut handler, lines 50-57)
  • Modify: cmd/web-ui/static/style.css (add ~80 lines for palette styles)

A floating search/navigation overlay triggered by Cmd+K (or Ctrl+K). Shows a filterable list of quick actions:

  • Navigate to pages (Dashboard, Sessions, Agents, Infrastructure)
  • Search sessions/runs by ID (reuses existing handleGlobalSearch)
  • Jump to specific agent by name
  • Toggle theme

Step 1: Add palette HTML and styles

In style.css, add after the toast notification styles (~line 571):

/* ── Command Palette ──────────────────────────────────────── */
.cmd-palette-backdrop {
  position: fixed;
  inset: 0;
  z-index: 200;
  background: rgba(0, 0, 0, 0.6);
  backdrop-filter: blur(4px);
  display: flex;
  justify-content: center;
  padding-top: 20vh;
  animation: fadeIn 150ms ease;
}

.cmd-palette {
  width: min(520px, 90vw);
  max-height: 400px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-xl);
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  animation: slideDown 150ms ease;
}

.cmd-palette-input-wrap {
  display: flex;
  align-items: center;
  padding: 0.75rem 1rem;
  border-bottom: 1px solid var(--border);
  gap: 0.5rem;
}

.cmd-palette-input-wrap svg { color: var(--text-dim); flex-shrink: 0; }

.cmd-palette-input {
  flex: 1;
  background: none;
  border: none;
  color: var(--text-bright);
  font-family: var(--font-body);
  font-size: 0.95rem;
  outline: none;
}

.cmd-palette-input::placeholder { color: var(--text-dim); }

.cmd-palette-results {
  overflow-y: auto;
  padding: 0.5rem;
  flex: 1;
}

.cmd-palette-item {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.5rem 0.75rem;
  border-radius: var(--radius);
  cursor: pointer;
  transition: background 100ms;
}

.cmd-palette-item:hover,
.cmd-palette-item.selected {
  background: var(--accent-dim);
}

.cmd-palette-item.selected {
  outline: 1px solid var(--accent-glow);
}

.cmd-palette-icon {
  width: 28px;
  height: 28px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius);
  background: var(--surface-2);
  color: var(--text-dim);
  font-size: 0.8rem;
  flex-shrink: 0;
}

.cmd-palette-item.selected .cmd-palette-icon,
.cmd-palette-item:hover .cmd-palette-icon {
  color: var(--accent);
  background: var(--accent-dim);
}

.cmd-palette-label {
  flex: 1;
  font-size: 0.85rem;
  color: var(--text);
}

.cmd-palette-label strong {
  color: var(--text-bright);
  font-weight: 500;
}

.cmd-palette-kbd {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--text-dim);
  background: var(--surface-2);
  padding: 0.15rem 0.4rem;
  border-radius: 3px;
  border: 1px solid var(--border-soft);
}

.cmd-palette-footer {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 0.5rem 1rem;
  border-top: 1px solid var(--border-soft);
  font-size: 0.7rem;
  color: var(--text-dim);
}

.cmd-palette-footer kbd {
  font-family: var(--font-mono);
  font-size: 0.65rem;
  background: var(--surface-2);
  padding: 0.1rem 0.3rem;
  border-radius: 2px;
  border: 1px solid var(--border-soft);
}

@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }

Step 2: Add palette JS logic

In app.js, add command palette logic after the copyToClipboard/renderCopyButton functions (~line 107). The palette should:

  • Build a static list of page navigation items
  • Build dynamic items from agentsState.agents keys
  • Filter items on keystroke
  • Navigate up/down with arrow keys, select with Enter, close with Escape
  • Use navigate() for page transitions
// ── Command Palette ─────────────────────────────────────
let paletteOpen = false;
let paletteSelectedIndex = 0;

function getCommandPaletteItems(query) {
  const items = [
    { label: 'Dashboard', path: '/', icon: '◉', shortcut: 'g d' },
    { label: 'Sessions', path: '/sessions', icon: '▶', shortcut: 'g s' },
    { label: 'Agents', path: '/agents', icon: '◎', shortcut: 'g a' },
    { label: 'Infrastructure', path: '/infrastructure', icon: '⚡', shortcut: 'g i' },
    { label: 'Toggle Theme', action: 'theme', icon: '◐' },
  ];

  // Add agent items dynamically
  if (agentsState && agentsState.agents) {
    for (const [key, agent] of Object.entries(agentsState.agents)) {
      items.push({
        label: 'Agent: ' + (agent.name || key),
        path: '/agents',
        action: 'select-agent',
        agentKey: key,
        icon: isAgentOnline(agent) ? '●' : '○',
      });
    }
  }

  if (!query) return items;
  const q = query.toLowerCase();
  return items.filter(item => item.label.toLowerCase().includes(q));
}

function openCommandPalette() {
  if (paletteOpen) return;
  paletteOpen = true;
  paletteSelectedIndex = 0;

  const backdrop = document.createElement('div');
  backdrop.className = 'cmd-palette-backdrop';
  backdrop.id = 'cmd-palette-backdrop';
  backdrop.innerHTML = `
    <div class="cmd-palette">
      <div class="cmd-palette-input-wrap">
        <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="5"/><line x1="11" y1="11" x2="14" y2="14"/></svg>
        <input class="cmd-palette-input" id="cmd-palette-input" type="text" placeholder="Type a command or search..." autofocus spellcheck="false" autocomplete="off">
      </div>
      <div class="cmd-palette-results" id="cmd-palette-results"></div>
      <div class="cmd-palette-footer">
        <span><kbd>↑↓</kbd> navigate</span>
        <span><kbd>↵</kbd> select</span>
        <span><kbd>esc</kbd> close</span>
      </div>
    </div>
  `;

  document.body.appendChild(backdrop);
  const input = document.getElementById('cmd-palette-input');
  input.focus();
  renderPaletteItems('');

  input.addEventListener('input', () => {
    paletteSelectedIndex = 0;
    renderPaletteItems(input.value);
  });

  input.addEventListener('keydown', (e) => {
    const items = document.querySelectorAll('.cmd-palette-item');
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      paletteSelectedIndex = Math.min(paletteSelectedIndex + 1, items.length - 1);
      updatePaletteSelection();
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      paletteSelectedIndex = Math.max(paletteSelectedIndex - 1, 0);
      updatePaletteSelection();
    } else if (e.key === 'Enter') {
      e.preventDefault();
      const selected = items[paletteSelectedIndex];
      if (selected) selected.click();
    } else if (e.key === 'Escape') {
      closeCommandPalette();
    }
  });

  backdrop.addEventListener('click', (e) => {
    if (e.target === backdrop) closeCommandPalette();
  });
}

function closeCommandPalette() {
  paletteOpen = false;
  const backdrop = document.getElementById('cmd-palette-backdrop');
  if (backdrop) backdrop.remove();
}

function renderPaletteItems(query) {
  const container = document.getElementById('cmd-palette-results');
  if (!container) return;
  const items = getCommandPaletteItems(query);

  // If query looks like an ID (8+ hex chars), add a search option
  if (query.length >= 8) {
    items.unshift({ label: 'Search for ID: ' + query, action: 'search', query, icon: '🔍' });
  }

  container.innerHTML = items.map((item, i) => `
    <div class="cmd-palette-item${i === paletteSelectedIndex ? ' selected' : ''}" data-index="${i}">
      <div class="cmd-palette-icon">${item.icon}</div>
      <span class="cmd-palette-label"><strong>${escapeHTML(item.label)}</strong></span>
      ${item.shortcut ? `<span class="cmd-palette-kbd">${item.shortcut}</span>` : ''}
    </div>
  `).join('');

  container.querySelectorAll('.cmd-palette-item').forEach((el, i) => {
    el.addEventListener('click', () => executePaletteItem(items[i]));
    el.addEventListener('mouseenter', () => {
      paletteSelectedIndex = i;
      updatePaletteSelection();
    });
  });
}

function updatePaletteSelection() {
  document.querySelectorAll('.cmd-palette-item').forEach((el, i) => {
    el.classList.toggle('selected', i === paletteSelectedIndex);
    if (i === paletteSelectedIndex) el.scrollIntoView({ block: 'nearest' });
  });
}

function executePaletteItem(item) {
  closeCommandPalette();
  if (item.action === 'theme') {
    cycleTheme();
  } else if (item.action === 'search') {
    handleGlobalSearch(item.query);
  } else if (item.action === 'select-agent') {
    navigate('/agents');
    setTimeout(() => selectAgent(item.agentKey, 'live'), 100);
  } else if (item.path) {
    navigate(item.path);
  }
}

Step 3: Wire Cmd+K and "g" key shortcuts into the global keydown handler

Replace the existing keyboard shortcut block (lines 50-57) with:

// Keyboard Shortcuts
let _pendingGoto = false;
document.addEventListener('keydown', (e) => {
  // Ignore when typing in inputs
  if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) {
    if (e.key === 'Escape') document.activeElement.blur();
    return;
  }

  // Cmd+K or Ctrl+K — command palette
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
    e.preventDefault();
    if (paletteOpen) closeCommandPalette();
    else openCommandPalette();
    return;
  }

  // '/' to focus search
  if (e.key === '/' && !paletteOpen) {
    e.preventDefault();
    const searchInput = document.getElementById('global-search');
    if (searchInput) searchInput.focus();
    return;
  }

  // Escape closes palette
  if (e.key === 'Escape' && paletteOpen) {
    closeCommandPalette();
    return;
  }

  // 'g' prefix for goto shortcuts
  if (e.key === 'g' && !_pendingGoto) {
    _pendingGoto = true;
    setTimeout(() => { _pendingGoto = false; }, 800);
    return;
  }

  if (_pendingGoto) {
    _pendingGoto = false;
    if (e.key === 'd') navigate('/');
    else if (e.key === 's') navigate('/sessions');
    else if (e.key === 'a') navigate('/agents');
    else if (e.key === 'i') navigate('/infrastructure');
    return;
  }
});

Step 4: Commit

git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
git commit -m "feat(web-ui): add command palette (Cmd+K) and goto keyboard shortcuts"

Task 2: Animated Counter Transitions on Dashboard

Files:

  • Modify: cmd/web-ui/static/app.js (add ~30 lines, modify renderSummaryCards)
  • Modify: cmd/web-ui/static/style.css (add ~10 lines)

When dashboard counters update via WebSocket, animate the number change with a brief scale-up + color flash instead of jumping instantly.

Step 1: Add counter animation CSS

In style.css, add after the summary card styles:

.summary-card-value.bumped {
  animation: counterBump 400ms ease;
}

@keyframes counterBump {
  0% { transform: scale(1); }
  30% { transform: scale(1.15); color: var(--card-accent, var(--accent)); }
  100% { transform: scale(1); }
}

.metric-pill-value.bumped {
  animation: counterBump 400ms ease;
}

Step 2: Add animateCounter utility in app.js

function animateCounter(elementId, newValue) {
  const el = document.getElementById(elementId);
  if (!el) return;
  const oldText = el.textContent;
  const newText = String(newValue);
  if (oldText === newText) return;
  el.textContent = newText;
  el.classList.remove('bumped');
  void el.offsetWidth; // force reflow
  el.classList.add('bumped');
}

Step 3: Update renderSummaryCards to use animateCounter

Replace the plain el(id, val) calls with animateCounter for the four main counters and the metrics strip values.

Step 4: Commit

git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
git commit -m "feat(web-ui): animated counter transitions on dashboard updates"

Task 3: Error Notification Badge in Header

Files:

  • Modify: cmd/web-ui/static/index.html (add badge element to nav)
  • Modify: cmd/web-ui/static/app.js (track error count, update badge)
  • Modify: cmd/web-ui/static/style.css (badge styles)

A small red dot/count badge appears on the Dashboard nav link when errors occur. Clears when user visits dashboard.

Step 1: Add badge element to header nav in index.html

Modify the Dashboard nav link to include a badge span:

<a href="/">Dashboard<span class="nav-badge" id="nav-error-badge"></span></a>

Step 2: Add badge CSS

nav a { position: relative; }

.nav-badge {
  display: none;
  position: absolute;
  top: -2px;
  right: -8px;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  background: var(--error);
  color: #fff;
  font-size: 0.6rem;
  font-weight: 600;
  border-radius: 8px;
  text-align: center;
  line-height: 16px;
  animation: badgePop 300ms ease;
}

.nav-badge.visible { display: block; }

@keyframes badgePop {
  0% { transform: scale(0); }
  60% { transform: scale(1.3); }
  100% { transform: scale(1); }
}

Step 3: Add badge tracking logic in app.js

let _unseenErrors = 0;

function incrementErrorBadge() {
  if (window.location.pathname === '/') return; // On dashboard, don't badge
  _unseenErrors++;
  const badge = document.getElementById('nav-error-badge');
  if (badge) {
    badge.textContent = _unseenErrors > 99 ? '99+' : String(_unseenErrors);
    badge.classList.add('visible');
  }
}

function clearErrorBadge() {
  _unseenErrors = 0;
  const badge = document.getElementById('nav-error-badge');
  if (badge) {
    badge.classList.remove('visible');
    badge.textContent = '';
  }
}

Call incrementErrorBadge() in the global WS handler when an error event arrives, and call clearErrorBadge() at the top of renderDashboard().

Step 4: Commit

git add cmd/web-ui/static/index.html cmd/web-ui/static/app.js cmd/web-ui/static/style.css
git commit -m "feat(web-ui): error notification badge in header nav"

Task 4: Session Status Filter Pills

Files:

  • Modify: cmd/web-ui/static/app.js (add filter UI and logic in renderSessions, ~40 lines)
  • Modify: cmd/web-ui/static/style.css (pill toggle styles, ~30 lines)

Add quick-filter pills above the sessions table: All | Active | Ended | With Errors. These filter client-side against the already-loaded sessions list.

Step 1: Add filter pill CSS

.filter-pills {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1rem;
}

.filter-pill {
  padding: 0.35rem 0.85rem;
  border: 1px solid var(--border);
  border-radius: 20px;
  background: transparent;
  color: var(--text-dim);
  font-family: var(--font-body);
  font-size: 0.78rem;
  cursor: pointer;
  transition: all 150ms;
}

.filter-pill:hover {
  border-color: var(--accent-glow);
  color: var(--text);
}

.filter-pill.active {
  background: var(--accent-dim);
  border-color: var(--accent);
  color: var(--accent);
}

.filter-pill .pill-count {
  margin-left: 0.35rem;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  opacity: 0.7;
}

Step 2: Add filter pills HTML in renderSessions

Insert after the .page-header div and before .filters:

const pillsHTML = `
  <div class="filter-pills" id="session-pills">
    <button class="filter-pill active" data-filter="all">All</button>
    <button class="filter-pill" data-filter="active">Active</button>
    <button class="filter-pill" data-filter="ended">Ended</button>
    <button class="filter-pill" data-filter="errored">With Errors</button>
  </div>
`;

Step 3: Add pill click handlers and client-side filtering

let sessionFilterMode = 'all';

function applySessionFilter() {
  const filtered = sessionsState.sessions.filter(s => {
    if (sessionFilterMode === 'active') return isSessionActive(s);
    if (sessionFilterMode === 'ended') return !isSessionActive(s);
    if (sessionFilterMode === 'errored') return s.has_errors;
    return true;
  });
  // Re-render table with filtered list
  renderFilteredSessionsTable(filtered);
}

Step 4: Wire pill buttons

document.querySelectorAll('#session-pills .filter-pill').forEach(btn => {
  btn.addEventListener('click', () => {
    document.querySelectorAll('#session-pills .filter-pill').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    sessionFilterMode = btn.dataset.filter;
    applySessionFilter();
  });
});

Step 5: Commit

git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
git commit -m "feat(web-ui): session status filter pills (all/active/ended/errored)"

Task 5: Dashboard Sparklines on Summary Cards

Files:

  • Modify: cmd/web-ui/static/app.js (add sparkline renderer, ~50 lines)
  • Modify: cmd/web-ui/static/style.css (sparkline styles, ~20 lines)

Add tiny inline SVG sparkline charts inside each of the four summary cards, showing the trend from the timeseries data. Uses the existing timeseries buckets — no new API calls.

Step 1: Add sparkline CSS

.summary-card-sparkline {
  position: absolute;
  bottom: 0;
  right: 0;
  width: 60%;
  height: 40px;
  opacity: 0.3;
  pointer-events: none;
}

.summary-card { position: relative; overflow: hidden; }

Step 2: Add sparkline SVG builder

function buildSparklineSVG(values, color) {
  if (!values || values.length < 2) return '';
  const max = Math.max(...values, 1);
  const w = 100;
  const h = 30;
  const points = values.map((v, i) => {
    const x = (i / (values.length - 1)) * w;
    const y = h - (v / max) * h;
    return `${x.toFixed(1)},${y.toFixed(1)}`;
  });
  const polyline = points.join(' ');
  // Area fill: close the path along the bottom
  const areaPath = `M0,${h} L${points.map(p => p).join(' L')} L${w},${h} Z`;
  return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" class="summary-card-sparkline">
    <path d="${areaPath}" fill="${color}" opacity="0.3"/>
    <polyline points="${polyline}" fill="none" stroke="${color}" stroke-width="1.5"/>
  </svg>`;
}

Step 3: Inject sparklines into summary cards after timeseries loads

After renderSummaryCards() is called with timeseries data, extract the relevant series and append SVG to each card:

function renderDashSparklines() {
  const ts = dashboardState.timeseries;
  if (!ts || !ts.series || ts.series.length < 2) return;
  const cards = document.querySelectorAll('.summary-card');
  if (cards.length < 4) return;

  const runsData = ts.series.map(b => b.runs || 0);
  const toolsData = ts.series.map(b => b.tools || 0);
  const errorsData = ts.series.map(b => b.errors || 0);
  const totalData = ts.series.map((b, i) => runsData[i] + toolsData[i] + errorsData[i]);

  // Remove existing sparklines
  cards.forEach(c => { const s = c.querySelector('.summary-card-sparkline'); if (s) s.remove(); });

  cards[0].insertAdjacentHTML('beforeend', buildSparklineSVG(totalData, 'var(--accent)'));
  cards[1].insertAdjacentHTML('beforeend', buildSparklineSVG(runsData, 'var(--success)'));
  cards[2].insertAdjacentHTML('beforeend', buildSparklineSVG(toolsData, 'var(--purple)'));
  cards[3].insertAdjacentHTML('beforeend', buildSparklineSVG(errorsData, 'var(--error)'));
}

Call renderDashSparklines() after renderTimeseriesChart() in both the initial load and loadTimeseries().

Step 4: Commit

git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
git commit -m "feat(web-ui): sparkline mini-charts on dashboard summary cards"

Task 6: Agent Lane Activity Sparklines

Files:

  • Modify: cmd/web-ui/static/app.js (add per-agent sparkline in lane render, ~30 lines)
  • Modify: cmd/web-ui/static/style.css (~15 lines)

Add a small activity sparkline bar in each agent lane header showing event frequency over recent time (computed from the agent's events array).

Step 1: Add lane sparkline CSS

.agent-lane-sparkline {
  display: flex;
  align-items: flex-end;
  gap: 1px;
  height: 20px;
  margin-top: 0.25rem;
}

.agent-lane-sparkline-bar {
  flex: 1;
  background: var(--accent);
  border-radius: 1px 1px 0 0;
  opacity: 0.5;
  min-height: 1px;
}

Step 2: Build agent activity histogram

function buildAgentActivityBars(agent, bucketCount) {
  const events = agent.events || [];
  if (events.length === 0) return '';
  const count = bucketCount || 20;
  const now = Date.now();
  const windowMS = 3600000; // 1 hour
  const bucketMS = windowMS / count;
  const buckets = new Array(count).fill(0);

  for (const evt of events) {
    const ts = new Date(getEnvelopeTS(evt)).getTime();
    const age = now - ts;
    if (age > windowMS || age < 0) continue;
    const idx = Math.min(count - 1, Math.floor((windowMS - age) / bucketMS));
    buckets[idx]++;
  }

  const max = Math.max(...buckets, 1);
  return `<div class="agent-lane-sparkline">${buckets.map(b => {
    const pct = (b / max * 100).toFixed(0);
    return `<div class="agent-lane-sparkline-bar" style="height:${Math.max(pct, 3)}%"></div>`;
  }).join('')}</div>`;
}

Step 3: Insert into renderAgentLanes

Add the sparkline HTML after the agent-lane-meta div in the lane header.

Step 4: Commit

git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
git commit -m "feat(web-ui): activity sparkline bars in agent lane headers"

Task 7: Page Transition Animations

Files:

  • Modify: cmd/web-ui/static/app.js (wrap route() with transition, ~15 lines)
  • Modify: cmd/web-ui/static/style.css (transition styles, ~15 lines)

Add a subtle crossfade when navigating between pages. The #app element fades out briefly, content replaces, then fades in.

Step 1: Add transition CSS

#app {
  transition: opacity 120ms ease;
}

#app.transitioning {
  opacity: 0;
  transform: translateY(4px);
}

Step 2: Wrap route() with transition

function route() {
  cleanupLiveViews();
  renderBreadcrumbs();

  app.classList.add('transitioning');
  requestAnimationFrame(() => {
    // After one frame (opacity transition starts), swap content
    setTimeout(() => {
      const path = window.location.pathname;
      // ... existing routing logic ...
      updateActiveNav();

      // Fade back in
      requestAnimationFrame(() => {
        app.classList.remove('transitioning');
      });
    }, 80); // Short delay for the fade-out
  });
}

Step 3: Commit

git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
git commit -m "feat(web-ui): subtle crossfade page transitions"

Task 8: Infrastructure Uptime Badges & Freshness Timers

Files:

  • Modify: cmd/web-ui/static/app.js (add uptime percentage calculation, freshness timer, ~30 lines)
  • Modify: cmd/web-ui/static/style.css (~20 lines)

Add uptime percentage badges to service cards and "last checked X ago" timers that count up in real-time.

Step 1: Add uptime badge CSS

.uptime-badge {
  display: inline-block;
  padding: 0.15rem 0.5rem;
  border-radius: 10px;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  font-weight: 500;
}

.uptime-badge.good { background: rgba(52, 211, 153, 0.15); color: var(--success); }
.uptime-badge.warn { background: rgba(251, 191, 36, 0.15); color: var(--warning); }
.uptime-badge.bad { background: rgba(248, 113, 113, 0.15); color: var(--error); }

.freshness-timer {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--text-dim);
}

Step 2: Calculate uptime from uptime_sec and add to service card headers

If svc.uptime_sec is available, compute a percentage relative to a 24h window and display as a small colored badge next to the service name:

function getUptimeBadge(uptimeSec) {
  if (!uptimeSec) return '';
  const hours = uptimeSec / 3600;
  const pct = Math.min(100, (hours / 24) * 100);
  const cls = pct >= 99 ? 'good' : pct >= 90 ? 'warn' : 'bad';
  return `<span class="uptime-badge ${cls}">${pct.toFixed(0)}% / 24h</span>`;
}

Step 3: Add freshness timers to VM cards

Add a data-updated-at attribute to the "Updated X ago" text, and refresh it with setInterval on the infrastructure page:

let _infraTimerInterval = null;

// In renderInfrastructure, after rendering:
_infraTimerInterval = setInterval(() => {
  document.querySelectorAll('.freshness-timer[data-ts]').forEach(el => {
    el.textContent = 'Updated ' + relativeTime(el.dataset.ts);
  });
}, 10000);

Step 4: Commit

git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
git commit -m "feat(web-ui): uptime badges and freshness timers on infrastructure page"

Task 9: Better Skeleton Loading Screens

Files:

  • Modify: cmd/web-ui/static/app.js (add content-aware skeletons per page, ~40 lines)
  • Modify: cmd/web-ui/static/style.css (~25 lines)

Replace generic skeleton rows with page-specific loading placeholders that match the shape of real content (card grids, timeline items, lane cards).

Step 1: Add skeleton variant CSS

.skeleton-card {
  background: var(--card);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-lg);
  padding: 1.25rem;
  min-height: 120px;
}

.skeleton-card .skeleton-line { margin-bottom: 0.5rem; }

.skeleton-summary-row {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 1rem;
  margin-bottom: 1.5rem;
}

.skeleton-timeline-item {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.75rem;
  border-radius: var(--radius);
  background: var(--card);
  margin-bottom: 0.5rem;
}

.skeleton-circle {
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: var(--surface-2);
  animation: skeletonPulse 1.5s ease-in-out infinite;
  flex-shrink: 0;
}

Step 2: Add page-specific skeleton builders

function dashboardSkeleton() {
  return `
    <div class="skeleton-summary-row">${Array(4).fill('<div class="skeleton-card"><div class="skeleton-line" style="width:40%"></div><div class="skeleton-line" style="width:20%;height:2rem"></div></div>').join('')}</div>
    <div class="skeleton-line" style="width:100%;height:200px;border-radius:var(--radius-lg)"></div>
  `;
}

function sessionsSkeleton() {
  return Array(6).fill(`
    <div class="skeleton-timeline-item">
      <div class="skeleton-circle"></div>
      <div style="flex:1"><div class="skeleton-line" style="width:60%"></div><div class="skeleton-line" style="width:30%"></div></div>
    </div>
  `).join('');
}

Step 3: Use these in the initial renders instead of skeletonRows()

Step 4: Commit

git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
git commit -m "feat(web-ui): content-aware skeleton loading screens"

Task 10: Session Duration Bar + Active Highlight

Files:

  • Modify: cmd/web-ui/static/app.js (modify renderSessionRow and refreshSessionsTable)
  • Modify: cmd/web-ui/static/style.css (~20 lines)

Add a small visual duration bar in the sessions table showing relative session length, and highlight active sessions with a subtle accent left-border.

Step 1: Add session row enhancement CSS

tr.clickable.active {
  border-left: 2px solid var(--accent);
  background: var(--accent-dim);
}

.session-duration-bar {
  display: inline-block;
  height: 4px;
  background: var(--accent);
  border-radius: 2px;
  min-width: 4px;
  max-width: 80px;
  opacity: 0.6;
  margin-left: 0.5rem;
  vertical-align: middle;
}

Step 2: Compute max duration and add bar to each row

In refreshSessionsTable, compute the longest session duration, then add a proportional bar element to each row's time column.

Step 3: Commit

git add cmd/web-ui/static/app.js cmd/web-ui/static/style.css
git commit -m "feat(web-ui): session duration bars and active session highlighting"

Task 11: Global Error-Aware WS Handler

Files:

  • Modify: cmd/web-ui/static/app.js (add global WS listener for error badge, ~20 lines)

Wire a persistent global WS subscription that runs across all pages (not cleaned up on route change) solely for error counting to support the header badge from Task 3.

Step 1: Add global WS subscription in DOMContentLoaded

// Global error tracking — persists across page navigations
subscribeWS(function globalErrorTracker(msg) {
  if (msg.type !== 'message') return;
  if (getEnvelopeType(msg.data) === 'error') {
    incrementErrorBadge();
  }
});

This goes in the DOMContentLoaded handler, after the search input setup.

Step 2: Commit

git add cmd/web-ui/static/app.js
git commit -m "feat(web-ui): global WebSocket error tracking for header badge"

Task 12: Polish Pass — Header Kbd Hint, Focus Rings, Hover States

Files:

  • Modify: cmd/web-ui/static/index.html (add Cmd+K hint to header)
  • Modify: cmd/web-ui/static/style.css (~40 lines of polish)

Final polish: add a ⌘K hint button next to the search bar, improve focus ring visibility for keyboard navigation, and refine hover states on interactive elements.

Step 1: Add Cmd+K button to header

In index.html, add after the search <kbd>/</kbd>:

<button class="cmd-k-hint" id="cmd-k-hint" title="Command palette" type="button">
  <kbd>⌘K</kbd>
</button>

Step 2: Add polish CSS

.cmd-k-hint {
  display: flex;
  align-items: center;
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius);
  padding: 0.2rem 0.5rem;
  color: var(--text-dim);
  font-family: var(--font-mono);
  font-size: 0.7rem;
  cursor: pointer;
  transition: all 150ms;
}

.cmd-k-hint:hover {
  border-color: var(--accent);
  color: var(--accent);
}

/* Better focus rings for keyboard navigation */
a:focus-visible,
button:focus-visible,
tr.clickable:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--radius);
}

/* Smooth hover lift on cards */
.vm-card:hover,
.service-card:hover,
.agent-lane:hover {
  transform: translateY(-2px);
  transition: transform 200ms ease, box-shadow 200ms ease;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}

Step 3: Wire the Cmd+K button

document.getElementById('cmd-k-hint')?.addEventListener('click', openCommandPalette);

Step 4: Commit

git add cmd/web-ui/static/index.html cmd/web-ui/static/app.js cmd/web-ui/static/style.css
git commit -m "feat(web-ui): polish pass — Cmd+K hint, focus rings, hover states"

Execution Order

Tasks are designed to be independent and can be done in any order, but the recommended sequence groups related work:

  1. Task 1 — Command Palette (foundation for keyboard navigation)
  2. Task 3 + 11 — Error badge + global WS handler (small, linked)
  3. Task 2 — Animated counters (dashboard polish)
  4. Task 5 — Dashboard sparklines (dashboard data richness)
  5. Task 4 — Session filter pills (sessions UX)
  6. Task 10 — Session duration bars (sessions UX)
  7. Task 6 — Agent lane sparklines (agents data richness)
  8. Task 8 — Infrastructure uptime + freshness (infra UX)
  9. Task 7 — Page transitions (global polish)
  10. Task 9 — Better skeletons (global polish)
  11. Task 12 — Final polish pass

Total: ~12 tasks, ~500 lines of JS, ~300 lines of CSS. All pure frontend, no backend changes.