# 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): ```css /* ── 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 ```javascript // ── 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 = `
`; 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) => `
${item.icon}
${escapeHTML(item.label)} ${item.shortcut ? `${item.shortcut}` : ''}
`).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: ```javascript // 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** ```bash 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: ```css .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** ```javascript 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** ```bash 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: ```html Dashboard ``` **Step 2: Add badge CSS** ```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** ```javascript 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** ```bash 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** ```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`: ```javascript const pillsHTML = `
`; ``` **Step 3: Add pill click handlers and client-side filtering** ```javascript 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** ```javascript 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** ```bash 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** ```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** ```javascript 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 ` `; } ``` **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: ```javascript 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** ```bash 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** ```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** ```javascript 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 `
${buckets.map(b => { const pct = (b / max * 100).toFixed(0); return `
`; }).join('')}
`; } ``` **Step 3: Insert into `renderAgentLanes`** Add the sparkline HTML after the `agent-lane-meta` div in the lane header. **Step 4: Commit** ```bash 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** ```css #app { transition: opacity 120ms ease; } #app.transitioning { opacity: 0; transform: translateY(4px); } ``` **Step 2: Wrap route() with transition** ```javascript 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** ```bash 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** ```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: ```javascript 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 `${pct.toFixed(0)}% / 24h`; } ``` **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: ```javascript 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** ```bash 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** ```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** ```javascript function dashboardSkeleton() { return `
${Array(4).fill('
').join('')}
`; } function sessionsSkeleton() { return Array(6).fill(`
`).join(''); } ``` **Step 3: Use these in the initial renders instead of `skeletonRows()`** **Step 4: Commit** ```bash 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** ```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** ```bash 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** ```javascript // 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** ```bash 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 `/`: ```html ``` **Step 2: Add polish CSS** ```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** ```javascript document.getElementById('cmd-k-hint')?.addEventListener('click', openCommandPalette); ``` **Step 4: Commit** ```bash 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.