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 : 1 px 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 "> →</ span >
125+ < span > {{ h.from }} → {{ 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