Skip to content

Commit 5abcf71

Browse files
committed
feat: add dark mode, transition animations, and history log to visualizer
Enhance the interactive visualizer with dark-first theming via CSS custom properties, taxi-style edge routing, node glow animations on transitions, and a scrollable transition history panel in the sidebar.
1 parent ddd363e commit 5abcf71

1 file changed

Lines changed: 187 additions & 32 deletions

File tree

mcp/ui/visualizer.html

Lines changed: 187 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,37 +9,80 @@
99
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"></script>
1010
<script src="https://cdn.jsdelivr.net/npm/cytoscape-dagre@2.5.0/cytoscape-dagre.min.js"></script>
1111
<style>
12+
:root {
13+
--bg: #0d1117;
14+
--sidebar-bg: #161b22;
15+
--text: #c9d1d9;
16+
--text-muted: #8b949e;
17+
--border: #30363d;
18+
--accent: #228be6;
19+
--accent-dim: rgba(34,139,230,0.15);
20+
--node-bg: #21262d;
21+
--node-border: #8b949e;
22+
--edge-color: #6e7681;
23+
--surface: #1c2128;
24+
--success: #3fb950;
25+
--code-bg: #1c2128;
26+
}
27+
:root.light {
28+
--bg: #ffffff;
29+
--sidebar-bg: #f6f8fa;
30+
--text: #1f2328;
31+
--text-muted: #656d76;
32+
--border: #d0d7de;
33+
--accent: #228be6;
34+
--accent-dim: rgba(34,139,230,0.12);
35+
--node-bg: #ffffff;
36+
--node-border: #57606a;
37+
--edge-color: #8c959f;
38+
--surface: #f6f8fa;
39+
--success: #1a7f37;
40+
--code-bg: #f0f2f4;
41+
}
1242
* { box-sizing: border-box; margin: 0; padding: 0; }
13-
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; display: flex; height: 100vh; overflow: hidden; background: #fff; color: #1a1a1a; }
43+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; display: flex; height: 100vh; overflow: hidden; background: var(--bg); color: var(--text); }
1444
#app { display: flex; width: 100%; height: 100%; }
15-
.sidebar { width: 320px; background: #f8f9fa; border-right: 1px solid #e0e0e0; display: flex; flex-direction: column; flex-shrink: 0; }
16-
.sidebar-header { padding: 16px; border-bottom: 1px solid #e0e0e0; }
17-
.sidebar-header h1 { font-size: 16px; font-weight: 600; }
18-
.sidebar-header .machine-id { font-size: 13px; color: #666; margin-top: 4px; }
45+
.sidebar { width: 320px; background: var(--sidebar-bg); border-right: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; }
46+
.sidebar-header { padding: 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: flex-start; }
47+
.sidebar-header-left h1 { font-size: 16px; font-weight: 600; }
48+
.sidebar-header-left .machine-id { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
49+
.theme-toggle { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; cursor: pointer; font-size: 14px; color: var(--text); line-height: 1; }
50+
.theme-toggle:hover { border-color: var(--accent); }
1951
.sidebar-body { flex: 1; overflow-y: auto; padding: 16px; }
2052
.panel { margin-bottom: 20px; }
21-
.panel-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #888; margin-bottom: 8px; }
22-
.state-badge { display: inline-block; padding: 4px 10px; background: #228be6; color: #fff; border-radius: 4px; font-size: 14px; font-weight: 500; }
23-
.state-badge.done { background: #40c057; }
24-
.state-path { font-size: 12px; color: #888; margin-top: 4px; }
25-
.event-btn { display: block; width: 100%; padding: 8px 12px; margin-bottom: 6px; background: #fff; border: 1px solid #ddd; border-radius: 6px; cursor: pointer; text-align: left; transition: border-color 0.15s, box-shadow 0.15s; }
26-
.event-btn:hover { border-color: #228be6; box-shadow: 0 0 0 2px rgba(34,139,230,0.15); }
53+
.panel-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; }
54+
.state-badge { display: inline-block; padding: 4px 10px; background: var(--accent); color: #fff; border-radius: 4px; font-size: 14px; font-weight: 500; }
55+
.state-badge.done { background: var(--success); }
56+
.state-path { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
57+
.event-btn { display: block; width: 100%; padding: 8px 12px; margin-bottom: 6px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; text-align: left; transition: border-color 0.15s, box-shadow 0.15s; color: var(--text); }
58+
.event-btn:hover { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim); }
2759
.event-btn .event-name { font-weight: 600; font-size: 13px; }
28-
.event-btn .event-target { font-size: 12px; color: #666; margin-top: 2px; }
29-
.event-btn .event-guard { font-size: 11px; color: #999; font-style: italic; }
30-
.no-events { color: #999; font-style: italic; font-size: 13px; }
31-
.context-display { font-family: "SF Mono", Monaco, "Cascadia Code", monospace; font-size: 12px; background: #eee; padding: 10px; border-radius: 6px; overflow: auto; max-height: 200px; white-space: pre-wrap; word-break: break-word; }
60+
.event-btn .event-target { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
61+
.event-btn .event-guard { font-size: 11px; color: var(--text-muted); font-style: italic; }
62+
.no-events { color: var(--text-muted); font-style: italic; font-size: 13px; }
63+
.context-display { font-family: "SF Mono", Monaco, "Cascadia Code", monospace; font-size: 12px; background: var(--code-bg); color: var(--text); padding: 10px; border-radius: 6px; overflow: auto; max-height: 200px; white-space: pre-wrap; word-break: break-word; border: 1px solid var(--border); }
3264
.graph-container { flex: 1; position: relative; }
3365
#cy { width: 100%; height: 100%; }
34-
.status-bar { position: absolute; bottom: 0; left: 0; right: 0; padding: 6px 12px; background: rgba(248,249,250,0.9); border-top: 1px solid #e0e0e0; font-size: 11px; color: #888; display: flex; justify-content: space-between; }
66+
.status-bar { position: absolute; bottom: 0; left: 0; right: 0; padding: 6px 12px; background: var(--sidebar-bg); border-top: 1px solid var(--border); font-size: 11px; color: var(--text-muted); display: flex; justify-content: space-between; }
67+
.history-list { max-height: 200px; overflow-y: auto; }
68+
.history-entry { font-size: 12px; padding: 4px 0; border-bottom: 1px solid var(--border); color: var(--text-muted); }
69+
.history-entry:last-child { border-bottom: none; }
70+
.history-entry .he-event { font-weight: 600; color: var(--accent); }
71+
.history-entry .he-arrow { margin: 0 4px; }
72+
.history-entry .he-time { font-size: 10px; color: var(--text-muted); float: right; }
73+
.clear-btn { font-size: 10px; color: var(--accent); cursor: pointer; background: none; border: none; text-transform: uppercase; letter-spacing: 0.3px; }
74+
.clear-btn:hover { text-decoration: underline; }
3575
</style>
3676
</head>
3777
<body>
3878
<div id="app">
3979
<div class="sidebar">
4080
<div class="sidebar-header">
41-
<h1>Statekit Visualizer</h1>
42-
<div class="machine-id" v-if="machine">{{ machine.id }}</div>
81+
<div class="sidebar-header-left">
82+
<h1>Statekit Visualizer</h1>
83+
<div class="machine-id" v-if="machine">{{ machine.id }}</div>
84+
</div>
85+
<button class="theme-toggle" @click="toggleTheme" :title="darkMode ? 'Switch to light mode' : 'Switch to dark mode'">{{ darkMode ? '☀' : '☾' }}</button>
4386
</div>
4487
<div class="sidebar-body" v-if="machine">
4588
<div class="panel">
@@ -67,9 +110,25 @@ <h1>Statekit Visualizer</h1>
67110
<div class="panel-title">Context</div>
68111
<pre class="context-display">{{ contextDisplay }}</pre>
69112
</div>
113+
114+
<div class="panel">
115+
<div class="panel-title">
116+
History
117+
<button v-if="transitionHistory.length" class="clear-btn" @click="transitionHistory = []">Clear</button>
118+
</div>
119+
<div v-if="transitionHistory.length === 0" class="no-events">No transitions yet</div>
120+
<div class="history-list" ref="historyList">
121+
<div v-for="(h, i) in transitionHistory" :key="i" class="history-entry">
122+
<span class="he-time">{{ h.time }}</span>
123+
<span class="he-event">{{ h.event }}</span>
124+
<span class="he-arrow">&rarr;</span>
125+
<span>{{ h.from }} &rarr; {{ h.to }}</span>
126+
</div>
127+
</div>
128+
</div>
70129
</div>
71130
<div class="sidebar-body" v-else>
72-
<p style="color: #888;">Waiting for machine data...</p>
131+
<p style="color: var(--text-muted);">Waiting for machine data...</p>
73132
</div>
74133
</div>
75134
<div class="graph-container">
@@ -88,10 +147,60 @@ <h1>Statekit Visualizer</h1>
88147
setup() {
89148
const machine = ref(null);
90149
const currentState = ref('');
150+
const previousState = ref('');
91151
const isDone = ref(false);
92152
const machineContext = ref({});
153+
const darkMode = ref(true);
154+
const transitionHistory = ref([]);
155+
const historyList = ref(null);
93156
let cy = null;
94157

158+
// Theme
159+
const savedTheme = localStorage.getItem('statekit-theme');
160+
if (savedTheme === 'light') {
161+
darkMode.value = false;
162+
document.documentElement.classList.add('light');
163+
}
164+
165+
function toggleTheme() {
166+
darkMode.value = !darkMode.value;
167+
document.documentElement.classList.toggle('light', !darkMode.value);
168+
localStorage.setItem('statekit-theme', darkMode.value ? 'dark' : 'light');
169+
if (cy) updateCyStyles();
170+
}
171+
172+
function getThemeColors() {
173+
const s = getComputedStyle(document.documentElement);
174+
return {
175+
nodeBg: s.getPropertyValue('--node-bg').trim(),
176+
nodeBorder: s.getPropertyValue('--node-border').trim(),
177+
text: s.getPropertyValue('--text').trim(),
178+
accent: s.getPropertyValue('--accent').trim(),
179+
accentDim: s.getPropertyValue('--accent-dim').trim(),
180+
edgeColor: s.getPropertyValue('--edge-color').trim(),
181+
surface: s.getPropertyValue('--surface').trim(),
182+
bg: s.getPropertyValue('--bg').trim(),
183+
};
184+
}
185+
186+
function getCyStyles() {
187+
const c = getThemeColors();
188+
return [
189+
{ selector: 'node', style: { 'content': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'background-color': c.nodeBg, 'border-width': 2, 'border-color': c.nodeBorder, 'color': c.text, 'width': 'label', 'height': 'label', 'padding': '16px', 'shape': 'round-rectangle', 'font-size': '14px', 'font-weight': 500 }},
190+
{ selector: 'node.active', style: { 'background-color': c.accentDim, 'border-color': c.accent, 'color': c.accent, 'overlay-opacity': 0.08, 'overlay-color': c.accent }},
191+
{ selector: 'node.initial', style: { 'border-width': 4 }},
192+
{ selector: 'node.final', style: { 'border-style': 'double', 'border-width': 6 }},
193+
{ selector: 'node:parent', style: { 'background-color': c.surface, 'background-opacity': 0.6, 'border-color': c.nodeBorder, 'border-style': 'dashed', 'text-valign': 'top', 'text-halign': 'center', 'padding': '24px', 'font-size': '12px', 'color': c.text }},
194+
{ selector: 'edge', style: { 'curve-style': 'taxi', 'taxi-direction': 'downward', 'taxi-turn': '50px', 'width': 2, 'target-arrow-shape': 'triangle', 'line-color': c.edgeColor, 'target-arrow-color': c.edgeColor, 'label': 'data(label)', 'font-size': '10px', 'text-rotation': 'autorotate', 'text-background-color': c.bg, 'text-background-opacity': 1, 'text-background-padding': '3px', 'text-background-shape': 'round-rectangle', 'color': c.text }},
195+
{ selector: 'edge.flash', style: { 'line-color': c.accent, 'target-arrow-color': c.accent, 'width': 3 }},
196+
];
197+
}
198+
199+
function updateCyStyles() {
200+
if (!cy) return;
201+
cy.style(getCyStyles());
202+
}
203+
95204
// Resolve initial state recursively
96205
function resolveInitial(stateId) {
97206
if (!machine.value) return stateId;
@@ -167,12 +276,14 @@ <h1>Statekit Visualizer</h1>
167276
// Edges
168277
Object.values(machine.value.states).forEach(state => {
169278
if (state.transitions) {
170-
state.transitions.forEach(t => {
279+
state.transitions.forEach((t, i) => {
171280
elements.push({
172281
data: {
282+
id: 'e-' + state.id + '-' + t.target + '-' + i,
173283
source: state.id,
174284
target: t.target,
175-
label: t.event + (t.guard ? ' [' + t.guard + ']' : '')
285+
label: t.event + (t.guard ? ' [' + t.guard + ']' : ''),
286+
eventType: t.event
176287
}
177288
});
178289
});
@@ -186,14 +297,7 @@ <h1>Statekit Visualizer</h1>
186297
elements,
187298
boxSelectionEnabled: false,
188299
autounselectify: true,
189-
style: [
190-
{ selector: 'node', style: { 'content': 'data(label)', 'text-valign': 'center', 'text-halign': 'center', 'background-color': '#fff', 'border-width': 2, 'border-color': '#333', 'width': 'label', 'height': 'label', 'padding': '12px', 'shape': 'round-rectangle', 'font-size': '14px' }},
191-
{ selector: 'node.active', style: { 'background-color': '#e7f5ff', 'border-color': '#228be6', 'color': '#228be6' }},
192-
{ selector: 'node.initial', style: { 'border-width': 4 }},
193-
{ selector: 'node.final', style: { 'border-style': 'double', 'border-width': 4 }},
194-
{ selector: 'node:parent', style: { 'background-color': '#f8f9fa', 'border-color': '#adb5bd', 'text-valign': 'top', 'text-halign': 'center', 'padding': '20px' }},
195-
{ selector: 'edge', style: { 'curve-style': 'bezier', 'width': 2, 'target-arrow-shape': 'triangle', 'line-color': '#999', 'target-arrow-color': '#999', 'label': 'data(label)', 'font-size': '11px', 'text-rotation': 'autorotate', 'text-background-color': '#fff', 'text-background-opacity': 1, 'text-background-padding': '2px' }}
196-
],
300+
style: getCyStyles(),
197301
layout: { name: 'dagre', rankDir: 'TB', padding: 50 }
198302
});
199303

@@ -211,6 +315,41 @@ <h1>Statekit Visualizer</h1>
211315
}
212316
}
213317

318+
function animateTransition(from, to, eventType) {
319+
if (!cy) return;
320+
321+
// Flash matching edge
322+
const edges = cy.edges().filter(e => {
323+
const d = e.data();
324+
return d.source === from && d.target === to;
325+
});
326+
if (edges.length) {
327+
edges.addClass('flash');
328+
setTimeout(() => edges.removeClass('flash'), 500);
329+
}
330+
331+
// Pulse new active node
332+
const targetNode = cy.getElementById(to);
333+
if (targetNode.length) {
334+
targetNode.animate({
335+
style: { 'overlay-opacity': 0.2 },
336+
duration: 200
337+
}).animate({
338+
style: { 'overlay-opacity': 0.08 },
339+
duration: 300
340+
});
341+
}
342+
}
343+
344+
function addHistoryEntry(event, from, to) {
345+
const now = new Date();
346+
const time = now.toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
347+
transitionHistory.value.unshift({ event, from, to, time });
348+
if (transitionHistory.value.length > 50) {
349+
transitionHistory.value.length = 50;
350+
}
351+
}
352+
214353
function sendEvent(eventType) {
215354
// Try MCP tool call if available
216355
if (window.parent !== window) {
@@ -238,11 +377,17 @@ <h1>Statekit Visualizer</h1>
238377
const t = transitions[0];
239378
const target = machine.value.states[t.target];
240379
if (target) {
380+
const from = currentState.value;
381+
let to;
241382
if (target.type === 'history') {
242-
currentState.value = resolveInitial(target.historyDefault || machine.value.initial);
383+
to = resolveInitial(target.historyDefault || machine.value.initial);
243384
} else {
244-
currentState.value = resolveInitial(t.target);
385+
to = resolveInitial(t.target);
245386
}
387+
previousState.value = from;
388+
currentState.value = to;
389+
addHistoryEntry(eventType, from, to);
390+
animateTransition(from, t.target, eventType);
246391
}
247392
}
248393
}
@@ -258,9 +403,15 @@ <h1>Statekit Visualizer</h1>
258403
}
259404
nextTick(() => initGraph());
260405
} else if (data && data.type === 'statekit:state-update') {
406+
const from = currentState.value;
261407
if (data.currentState) {
408+
previousState.value = from;
262409
currentState.value = data.currentState;
263410
isDone.value = !!data.done;
411+
if (from !== data.currentState) {
412+
addHistoryEntry(data.event || '?', from, data.currentState);
413+
animateTransition(from, data.currentState, data.event || '');
414+
}
264415
}
265416
if (data.context !== undefined) {
266417
machineContext.value = data.context;
@@ -284,11 +435,15 @@ <h1>Statekit Visualizer</h1>
284435
machine,
285436
currentState,
286437
isDone,
438+
darkMode,
287439
statePath,
288440
availableTransitions,
289441
transitionCount,
290442
contextDisplay,
291-
sendEvent
443+
transitionHistory,
444+
historyList,
445+
sendEvent,
446+
toggleTheme
292447
};
293448
}
294449
}).mount('#app');

0 commit comments

Comments
 (0)