@scenetest/vite-plugin 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +7 -5
  3. package/dist/index.js.map +1 -1
  4. package/dist/panels/observer/audio.d.ts +81 -0
  5. package/dist/panels/observer/audio.d.ts.map +1 -0
  6. package/dist/panels/observer/audio.js +296 -0
  7. package/dist/panels/observer/audio.js.map +1 -0
  8. package/dist/panels/observer/auto.d.ts +10 -0
  9. package/dist/panels/observer/auto.d.ts.map +1 -0
  10. package/dist/panels/observer/auto.js +11 -0
  11. package/dist/panels/observer/auto.js.map +1 -0
  12. package/dist/panels/observer/fs-viewer.d.ts +20 -0
  13. package/dist/panels/observer/fs-viewer.d.ts.map +1 -0
  14. package/dist/panels/observer/fs-viewer.js +536 -0
  15. package/dist/panels/observer/fs-viewer.js.map +1 -0
  16. package/dist/panels/observer/fullscreen.d.ts +24 -0
  17. package/dist/panels/observer/fullscreen.d.ts.map +1 -0
  18. package/dist/panels/observer/fullscreen.js +701 -0
  19. package/dist/panels/observer/fullscreen.js.map +1 -0
  20. package/dist/panels/observer/history.d.ts +41 -0
  21. package/dist/panels/observer/history.d.ts.map +1 -0
  22. package/dist/panels/observer/history.js +307 -0
  23. package/dist/panels/observer/history.js.map +1 -0
  24. package/dist/panels/observer/index.d.ts +33 -0
  25. package/dist/panels/observer/index.d.ts.map +1 -0
  26. package/dist/panels/observer/index.js +128 -0
  27. package/dist/panels/observer/index.js.map +1 -0
  28. package/dist/panels/observer/panel.d.ts +12 -0
  29. package/dist/panels/observer/panel.d.ts.map +1 -0
  30. package/dist/panels/observer/panel.js +461 -0
  31. package/dist/panels/observer/panel.js.map +1 -0
  32. package/dist/panels/observer/render.d.ts +109 -0
  33. package/dist/panels/observer/render.d.ts.map +1 -0
  34. package/dist/panels/observer/render.js +760 -0
  35. package/dist/panels/observer/render.js.map +1 -0
  36. package/dist/panels/observer/state.d.ts +57 -0
  37. package/dist/panels/observer/state.d.ts.map +1 -0
  38. package/dist/panels/observer/state.js +187 -0
  39. package/dist/panels/observer/state.js.map +1 -0
  40. package/dist/panels/observer/styles.d.ts +6 -0
  41. package/dist/panels/observer/styles.d.ts.map +1 -0
  42. package/dist/panels/observer/styles.js +1706 -0
  43. package/dist/panels/observer/styles.js.map +1 -0
  44. package/dist/panels/observer/types.d.ts +102 -0
  45. package/dist/panels/observer/types.d.ts.map +1 -0
  46. package/dist/panels/observer/types.js +5 -0
  47. package/dist/panels/observer/types.js.map +1 -0
  48. package/dist/panels/observer/utils.d.ts +45 -0
  49. package/dist/panels/observer/utils.d.ts.map +1 -0
  50. package/dist/panels/observer/utils.js +101 -0
  51. package/dist/panels/observer/utils.js.map +1 -0
  52. package/dist/panels/recorder/auto.d.ts +10 -0
  53. package/dist/panels/recorder/auto.d.ts.map +1 -0
  54. package/dist/panels/recorder/auto.js +11 -0
  55. package/dist/panels/recorder/auto.js.map +1 -0
  56. package/dist/panels/recorder/capture.d.ts +18 -0
  57. package/dist/panels/recorder/capture.d.ts.map +1 -0
  58. package/dist/panels/recorder/capture.js +218 -0
  59. package/dist/panels/recorder/capture.js.map +1 -0
  60. package/dist/panels/recorder/index.d.ts +41 -0
  61. package/dist/panels/recorder/index.d.ts.map +1 -0
  62. package/dist/panels/recorder/index.js +208 -0
  63. package/dist/panels/recorder/index.js.map +1 -0
  64. package/dist/panels/recorder/panel.d.ts +55 -0
  65. package/dist/panels/recorder/panel.d.ts.map +1 -0
  66. package/dist/panels/recorder/panel.js +284 -0
  67. package/dist/panels/recorder/panel.js.map +1 -0
  68. package/dist/panels/recorder/reverse-selector.d.ts +31 -0
  69. package/dist/panels/recorder/reverse-selector.d.ts.map +1 -0
  70. package/dist/panels/recorder/reverse-selector.js +116 -0
  71. package/dist/panels/recorder/reverse-selector.js.map +1 -0
  72. package/dist/panels/recorder/styles.d.ts +5 -0
  73. package/dist/panels/recorder/styles.d.ts.map +1 -0
  74. package/dist/panels/recorder/styles.js +300 -0
  75. package/dist/panels/recorder/styles.js.map +1 -0
  76. package/dist/panels/recorder/types.d.ts +51 -0
  77. package/dist/panels/recorder/types.d.ts.map +1 -0
  78. package/dist/panels/recorder/types.js +15 -0
  79. package/dist/panels/recorder/types.js.map +1 -0
  80. package/dist/strip.d.ts.map +1 -1
  81. package/dist/strip.js +6 -3
  82. package/dist/strip.js.map +1 -1
  83. package/dist/transform.d.ts.map +1 -1
  84. package/dist/transform.js +5 -2
  85. package/dist/transform.js.map +1 -1
  86. package/package.json +12 -6
@@ -0,0 +1,701 @@
1
+ /**
2
+ * Fullscreen window management
3
+ */
4
+ import { groups, passCount, failCount, fullscreenWindow, filter, viewMode, fileViewMode, sequenceLocationKey, sequenceFromView, locationGroups, setFullscreenWindow, setFilter, setViewMode, setFileViewMode, setSequenceLocation, clearAll, panel, toggleGroupCollapsed, } from './state.js';
5
+ import { filterItems, openInEditor } from './utils.js';
6
+ import { renderFullscreenGroup, renderLocationRow, renderSequenceEntry, renderSequenceHeader, renderChordTooltip, renderPianoRoll, renderBackButton, attachEventListeners, buildFileTree, renderFileTree } from './render.js';
7
+ import { fullscreenStyles } from './styles.js';
8
+ import { updatePanel } from './panel.js';
9
+ import { clearSymphony, playSymphony, stopSymphony, toggleMute, isPlaying, getSymphonyInfo, initAudio, playGroupChord, playAssertionSound, } from './audio.js';
10
+ import { assertions, GROUP_THRESHOLD_MS, toggleFileTreeCollapsed } from './state.js';
11
+ import { mountFsViewer } from './fs-viewer.js';
12
+ // Track filesystem viewer cleanup function so we can tear it down on view mode change
13
+ let fsViewerCleanup = null;
14
+ /**
15
+ * Clean up any active filesystem viewer before switching views
16
+ */
17
+ function cleanupFsViewer() {
18
+ if (fsViewerCleanup) {
19
+ fsViewerCleanup();
20
+ fsViewerCleanup = null;
21
+ }
22
+ }
23
+ /**
24
+ * Set up event listeners for fullscreen view rendered content.
25
+ * Uses event delegation via attachEventListeners from render.js.
26
+ */
27
+ function setupFullscreenEventListeners(_doc, listEl) {
28
+ attachEventListeners(listEl, {
29
+ openInEditorFullscreen: (location) => {
30
+ // In fullscreen, we need to call the opener's function or our own
31
+ const opener = window.opener;
32
+ if (opener && opener.__scenetest_openInEditor) {
33
+ opener.__scenetest_openInEditor(location);
34
+ }
35
+ else if (window.__scenetest_openInEditor) {
36
+ window.__scenetest_openInEditor(location);
37
+ }
38
+ else {
39
+ openInEditor(location);
40
+ }
41
+ },
42
+ showSequence: (key) => {
43
+ // Show sequence view - call opener's function or our own
44
+ const opener = window.opener;
45
+ if (opener && opener.__scenetest_showSequence) {
46
+ opener.__scenetest_showSequence(key);
47
+ }
48
+ else if (window.__scenetest_showSequence) {
49
+ window.__scenetest_showSequence(key);
50
+ }
51
+ else {
52
+ showSequence(key);
53
+ }
54
+ },
55
+ backToLocationView: () => {
56
+ backFromSequence();
57
+ },
58
+ toggleCollapsed: (groupId) => {
59
+ toggleGroupCollapsed(groupId);
60
+ },
61
+ toggleFileTree: (path) => {
62
+ toggleFileTreeCollapsed(path);
63
+ updateFullscreenWindow();
64
+ },
65
+ });
66
+ }
67
+ /**
68
+ * Get the HTML for the fullscreen window
69
+ */
70
+ function getFullscreenHTML() {
71
+ return `
72
+ <!DOCTYPE html>
73
+ <html>
74
+ <head>
75
+ <title>scenetest - Inline Assertions</title>
76
+ <style>${fullscreenStyles}</style>
77
+ </head>
78
+ <body>
79
+ <div id="header">
80
+ <span id="title"><span class="icon"><span>\uD83C\uDFAC</span></span>scenetest</span>
81
+ <div id="controls">
82
+ <div id="counts">
83
+ <span class="count pass" id="pass-count">\u2713 0</span>
84
+ <span class="count fail" id="fail-count">\u2717 0</span>
85
+ </div>
86
+ <div id="view-modes" class="btn-group">
87
+ <button class="btn active" id="view-grouped" title="Group by time">Timeline</button>
88
+ <button class="btn" id="view-byLocation" title="Group by code location">By Location</button>
89
+ </div>
90
+ <div id="filters" class="btn-group">
91
+ <button class="btn active" id="filter-all">All</button>
92
+ <button class="btn" id="filter-fails">Errors</button>
93
+ <button class="btn" id="filter-passes">Passes</button>
94
+ </div>
95
+ <span class="separator"></span>
96
+ <div id="audio-controls" class="btn-group">
97
+ <button class="btn audio-btn" id="audio-mute" title="Toggle sound">\uD83D\uDD0A</button>
98
+ <button class="btn audio-btn" id="audio-play" title="Play symphony">\u25B6</button>
99
+ </div>
100
+ <span class="separator"></span>
101
+ <button class="btn" id="scenetest-clear-full">Clear</button>
102
+ </div>
103
+ </div>
104
+ <div id="list">
105
+ <div id="empty">
106
+ <div id="empty-icon">\uD83C\uDFAC</div>
107
+ <div>Interact with your app to see inline assertions appear here...</div>
108
+ </div>
109
+ </div>
110
+ </body>
111
+ </html>
112
+ `;
113
+ }
114
+ /**
115
+ * Set filter from fullscreen and sync to main panel
116
+ */
117
+ function setFullscreenFilter(newFilter) {
118
+ setFilter(newFilter);
119
+ if (panel) {
120
+ panel.querySelector('#scenetest-filter-all')?.classList.toggle('active', filter === 'all');
121
+ panel.querySelector('#scenetest-filter-fails')?.classList.toggle('active', filter === 'fails');
122
+ panel.querySelector('#scenetest-pass')?.classList.toggle('active', filter === 'passes');
123
+ panel.querySelector('#scenetest-fail')?.classList.toggle('active', filter === 'fails');
124
+ }
125
+ updatePanel();
126
+ updateFullscreenWindow();
127
+ }
128
+ /**
129
+ * Set view mode from fullscreen
130
+ */
131
+ function setFullscreenViewMode(newMode) {
132
+ setViewMode(newMode);
133
+ updateFullscreenWindow();
134
+ }
135
+ /**
136
+ * Show sequence view for a specific location
137
+ */
138
+ export function showSequence(locationKey) {
139
+ setSequenceLocation(locationKey);
140
+ updateFullscreenWindow();
141
+ }
142
+ /**
143
+ * Go back from sequence view to the view we came from
144
+ */
145
+ export function backFromSequence() {
146
+ setViewMode(sequenceFromView);
147
+ updateFullscreenWindow();
148
+ }
149
+ // Track which group to scroll to after fullscreen opens
150
+ let scrollToGroupId = null;
151
+ /**
152
+ * Open the fullscreen window and optionally scroll to a specific group
153
+ */
154
+ export function openFullscreen(groupId) {
155
+ scrollToGroupId = groupId ?? null;
156
+ if (fullscreenWindow && !fullscreenWindow.closed) {
157
+ fullscreenWindow.focus();
158
+ if (scrollToGroupId !== null) {
159
+ scrollToGroup(scrollToGroupId);
160
+ }
161
+ return;
162
+ }
163
+ const win = window.open('', 'scenetest-fullscreen', 'width=900,height=700');
164
+ if (!win) {
165
+ alert('Please allow popups for this site to use fullscreen mode.');
166
+ return;
167
+ }
168
+ setFullscreenWindow(win);
169
+ win.document.write(getFullscreenHTML());
170
+ win.document.close();
171
+ // Set up event handlers
172
+ const doc = win.document;
173
+ doc.getElementById('scenetest-clear-full')?.addEventListener('click', () => {
174
+ clearAll();
175
+ clearSymphony();
176
+ updatePanel();
177
+ updateFullscreenWindow();
178
+ });
179
+ // Audio controls
180
+ doc.getElementById('audio-mute')?.addEventListener('click', () => {
181
+ initAudio();
182
+ const nowMuted = toggleMute();
183
+ const muteBtn = doc.getElementById('audio-mute');
184
+ if (muteBtn) {
185
+ muteBtn.textContent = nowMuted ? '\uD83D\uDD07' : '\uD83D\uDD0A';
186
+ muteBtn.classList.toggle('muted', nowMuted);
187
+ }
188
+ // Sync with main panel
189
+ const panelMuteBtn = panel?.querySelector('#scenetest-mute');
190
+ if (panelMuteBtn) {
191
+ panelMuteBtn.textContent = nowMuted ? '\uD83D\uDD07' : '\uD83D\uDD0A';
192
+ panelMuteBtn.classList.toggle('muted', nowMuted);
193
+ }
194
+ });
195
+ doc.getElementById('audio-play')?.addEventListener('click', () => {
196
+ initAudio();
197
+ const playBtn = doc.getElementById('audio-play');
198
+ if (isPlaying()) {
199
+ stopSymphony();
200
+ if (playBtn) {
201
+ playBtn.textContent = '\u25B6';
202
+ playBtn.classList.remove('playing');
203
+ }
204
+ }
205
+ else {
206
+ const info = getSymphonyInfo();
207
+ if (info.eventCount === 0)
208
+ return;
209
+ playSymphony();
210
+ if (playBtn) {
211
+ playBtn.textContent = '\u23F9';
212
+ playBtn.classList.add('playing');
213
+ }
214
+ // Set up callback for when symphony completes
215
+ ;
216
+ window.__scenetest_symphonyComplete = () => {
217
+ const btn = doc.getElementById('audio-play');
218
+ if (btn) {
219
+ btn.textContent = '\u25B6';
220
+ btn.classList.remove('playing');
221
+ }
222
+ // Also update main panel button
223
+ const panelBtn = panel?.querySelector('#scenetest-play');
224
+ if (panelBtn) {
225
+ panelBtn.textContent = '\u25B6';
226
+ panelBtn.classList.remove('playing');
227
+ }
228
+ };
229
+ }
230
+ });
231
+ doc.getElementById('filter-all')?.addEventListener('click', () => {
232
+ setFullscreenFilter('all');
233
+ });
234
+ doc.getElementById('filter-fails')?.addEventListener('click', () => {
235
+ setFullscreenFilter('fails');
236
+ });
237
+ doc.getElementById('filter-passes')?.addEventListener('click', () => {
238
+ setFullscreenFilter('passes');
239
+ });
240
+ // View mode handlers
241
+ doc.getElementById('view-grouped')?.addEventListener('click', () => {
242
+ setFullscreenViewMode('grouped');
243
+ });
244
+ doc.getElementById('view-byLocation')?.addEventListener('click', () => {
245
+ setFullscreenViewMode('byLocation');
246
+ });
247
+ // Set up event delegation once on the list element (not on every re-render)
248
+ const listEl = doc.getElementById('list');
249
+ if (listEl) {
250
+ setupFullscreenEventListeners(doc, listEl);
251
+ }
252
+ updateFullscreenWindow();
253
+ // Scroll to group if requested
254
+ if (scrollToGroupId !== null) {
255
+ scrollToGroup(scrollToGroupId);
256
+ scrollToGroupId = null;
257
+ }
258
+ }
259
+ /**
260
+ * Scroll to a specific group in the fullscreen window and highlight it
261
+ */
262
+ function scrollToGroup(groupId) {
263
+ if (!fullscreenWindow || fullscreenWindow.closed)
264
+ return;
265
+ const doc = fullscreenWindow.document;
266
+ const groupEl = doc.querySelector(`[data-group-id="${groupId}"]`);
267
+ if (groupEl) {
268
+ // Expand the group if collapsed
269
+ groupEl.classList.remove('collapsed');
270
+ // Scroll into view
271
+ groupEl.scrollIntoView({ behavior: 'auto', block: 'start' });
272
+ // Clear any previous highlight and add to this group
273
+ const prevHighlighted = doc.querySelector('.group.highlighted');
274
+ if (prevHighlighted)
275
+ prevHighlighted.classList.remove('highlighted');
276
+ groupEl.classList.add('highlighted');
277
+ }
278
+ }
279
+ /**
280
+ * Open fullscreen and scroll to a specific group (called from small panel item clicks)
281
+ */
282
+ export function openFullscreenToGroup(groupId) {
283
+ openFullscreen(groupId);
284
+ }
285
+ /**
286
+ * Update the fullscreen window content
287
+ */
288
+ export function updateFullscreenWindow() {
289
+ if (!fullscreenWindow || fullscreenWindow.closed)
290
+ return;
291
+ const doc = fullscreenWindow.document;
292
+ const passCountEl = doc.getElementById('pass-count');
293
+ const failCountEl = doc.getElementById('fail-count');
294
+ if (passCountEl)
295
+ passCountEl.textContent = `\u2713 ${passCount}`;
296
+ if (failCountEl)
297
+ failCountEl.textContent = `\u2717 ${failCount}`;
298
+ // Update filter button states
299
+ doc.getElementById('filter-all')?.classList.toggle('active', filter === 'all');
300
+ doc.getElementById('filter-fails')?.classList.toggle('active', filter === 'fails');
301
+ doc.getElementById('filter-passes')?.classList.toggle('active', filter === 'passes');
302
+ // Update view mode button states - sequence view keeps the tab it came from highlighted
303
+ const activeTab = viewMode === 'sequence' ? sequenceFromView : viewMode;
304
+ doc.getElementById('view-grouped')?.classList.toggle('active', activeTab === 'grouped');
305
+ doc.getElementById('view-byLocation')?.classList.toggle('active', activeTab === 'byLocation');
306
+ const listEl = doc.getElementById('list');
307
+ if (!listEl)
308
+ return;
309
+ // Clean up filesystem viewer when switching away from byLocation or when file view mode changes to tree
310
+ if (viewMode !== 'byLocation' || fileViewMode !== 'graph') {
311
+ cleanupFsViewer();
312
+ }
313
+ // Render based on view mode
314
+ if (viewMode === 'sequence' && sequenceLocationKey) {
315
+ renderSequenceView(doc, listEl);
316
+ return;
317
+ }
318
+ if (viewMode === 'byLocation') {
319
+ renderByLocationView(doc, listEl);
320
+ return;
321
+ }
322
+ // Default: grouped (timeline) view
323
+ renderGroupedView(doc, listEl);
324
+ }
325
+ /**
326
+ * Render the grouped (timeline) view
327
+ */
328
+ function renderGroupedView(doc, listEl) {
329
+ // Save scroll state before re-rendering
330
+ const scrollTop = doc.documentElement.scrollTop || doc.body.scrollTop;
331
+ const isScrolled = scrollTop > 50;
332
+ let anchorGroupId = null;
333
+ let anchorOffset = 0;
334
+ if (isScrolled) {
335
+ const groupEls = listEl.querySelectorAll('[data-group-id]');
336
+ for (const el of groupEls) {
337
+ const rect = el.getBoundingClientRect();
338
+ if (rect.top >= -rect.height / 2) {
339
+ anchorGroupId = parseInt(el.getAttribute('data-group-id') || '', 10);
340
+ anchorOffset = rect.top;
341
+ break;
342
+ }
343
+ }
344
+ }
345
+ const filteredGroups = groups
346
+ .map(g => ({
347
+ ...g,
348
+ items: filterItems(g.items),
349
+ }))
350
+ .filter(g => g.items.length > 0);
351
+ if (filteredGroups.length === 0) {
352
+ const icon = filter === 'fails' ? '\u2713' : '\uD83C\uDFAC';
353
+ const message = filter === 'fails'
354
+ ? 'No errors! All assertions passed.'
355
+ : 'Interact with your app to see inline assertions appear here...';
356
+ listEl.innerHTML = `
357
+ <div id="empty">
358
+ <div id="empty-icon">${icon}</div>
359
+ <div>${message}</div>
360
+ </div>
361
+ `;
362
+ return;
363
+ }
364
+ listEl.innerHTML = filteredGroups
365
+ .map(g => renderFullscreenGroup(g))
366
+ .reverse()
367
+ .join('');
368
+ // Restore scroll position
369
+ if (anchorGroupId !== null) {
370
+ const anchorEl = listEl.querySelector(`[data-group-id="${anchorGroupId}"]`);
371
+ if (anchorEl) {
372
+ const newRect = anchorEl.getBoundingClientRect();
373
+ const scrollAdjustment = newRect.top - anchorOffset;
374
+ doc.documentElement.scrollTop += scrollAdjustment;
375
+ doc.body.scrollTop += scrollAdjustment;
376
+ }
377
+ }
378
+ }
379
+ /**
380
+ * Render the "by location" view showing one row per unique code location
381
+ */
382
+ function renderByLocationView(_doc, listEl) {
383
+ // Clean up graph if switching to tree
384
+ if (fileViewMode !== 'graph') {
385
+ cleanupFsViewer();
386
+ }
387
+ // Get all location groups and sort by most recent activity
388
+ const locations = Array.from(locationGroups.values())
389
+ .sort((a, b) => b.lastTimestamp - a.lastTimestamp);
390
+ // Apply filter
391
+ let filteredLocations = locations;
392
+ if (filter === 'fails') {
393
+ filteredLocations = locations.filter(loc => loc.entries.some(e => !e.result));
394
+ }
395
+ else if (filter === 'passes') {
396
+ filteredLocations = locations.filter(loc => loc.entries.some(e => e.result));
397
+ }
398
+ if (filteredLocations.length === 0) {
399
+ const icon = filter === 'fails' ? '\u2713' : '\uD83C\uDFAC';
400
+ const message = filter === 'fails'
401
+ ? 'No errors! All assertions passed.'
402
+ : 'Interact with your app to see inline assertions appear here...';
403
+ listEl.innerHTML = `
404
+ <div id="empty">
405
+ <div id="empty-icon">${icon}</div>
406
+ <div>${message}</div>
407
+ </div>
408
+ `;
409
+ return;
410
+ }
411
+ // File viewer toggle (tree vs graph)
412
+ const fileViewerToggle = `
413
+ <div class="file-view-toggle">
414
+ <button class="btn file-view-btn${fileViewMode === 'tree' ? ' active' : ''}" id="fv-tree" title="File tree view">Tree</button>
415
+ <button class="btn file-view-btn${fileViewMode === 'graph' ? ' active' : ''}" id="fv-graph" title="Force-directed graph">Graph</button>
416
+ </div>
417
+ `;
418
+ // Render the file viewer section (tree or graph)
419
+ let fileViewerHTML;
420
+ if (fileViewMode === 'graph') {
421
+ fileViewerHTML = `<div id="fs-viewer-container" class="fs-viewer-container"></div>`;
422
+ }
423
+ else {
424
+ const tree = buildFileTree(filteredLocations);
425
+ fileViewerHTML = renderFileTree(tree);
426
+ }
427
+ listEl.innerHTML = `
428
+ ${renderPianoRoll(filteredLocations, assertions)}
429
+ ${fileViewerToggle}
430
+ ${fileViewerHTML}
431
+ <div class="location-list">
432
+ ${filteredLocations.map(loc => renderLocationRow(loc)).join('')}
433
+ </div>
434
+ `;
435
+ // Wire up tree/graph toggle buttons
436
+ _doc.getElementById('fv-tree')?.addEventListener('click', () => {
437
+ cleanupFsViewer();
438
+ setFileViewMode('tree');
439
+ updateFullscreenWindow();
440
+ });
441
+ _doc.getElementById('fv-graph')?.addEventListener('click', () => {
442
+ setFileViewMode('graph');
443
+ updateFullscreenWindow();
444
+ });
445
+ // Mount graph if in graph mode
446
+ if (fileViewMode === 'graph') {
447
+ const container = listEl.querySelector('#fs-viewer-container');
448
+ if (container) {
449
+ cleanupFsViewer();
450
+ fsViewerCleanup = mountFsViewer(container, filteredLocations);
451
+ }
452
+ }
453
+ // Set up click handlers for piano roll columns and note badges
454
+ setupPianoRollHandlers(_doc, listEl);
455
+ setupNoteClickHandlers(_doc, listEl);
456
+ }
457
+ /**
458
+ * Render the sequence view for a specific location
459
+ */
460
+ function renderSequenceView(_doc, listEl) {
461
+ const group = locationGroups.get(sequenceLocationKey);
462
+ if (!group) {
463
+ // Location no longer exists, go back
464
+ setViewMode('byLocation');
465
+ renderByLocationView(_doc, listEl);
466
+ return;
467
+ }
468
+ // Apply filter to entries
469
+ let entries = group.entries;
470
+ if (filter === 'fails') {
471
+ entries = entries.filter(e => !e.result);
472
+ }
473
+ else if (filter === 'passes') {
474
+ entries = entries.filter(e => e.result);
475
+ }
476
+ // Reverse entries so most recent is at top
477
+ const reversedEntries = entries.slice().reverse();
478
+ const entryCount = reversedEntries.length;
479
+ listEl.innerHTML = `
480
+ ${renderBackButton(sequenceFromView)}
481
+ ${renderSequenceHeader(group)}
482
+ <div class="sequence-direction-hint">
483
+ <span class="direction-arrow">\u2191</span> Most recent at top
484
+ </div>
485
+ <div class="sequence-list">
486
+ ${reversedEntries.map((entry, i) => {
487
+ const isFirst = i === 0; // Most recent (top)
488
+ const isLast = i === entryCount - 1; // Oldest (bottom)
489
+ return renderSequenceEntry(entry, group.location, isFirst, isLast);
490
+ }).join('')}
491
+ </div>
492
+ ${entryCount > 1 ? '<div class="sequence-end-label">First event</div>' : ''}
493
+ `;
494
+ // Set up chord hover handlers
495
+ setupChordHoverHandlers(_doc, listEl);
496
+ // Set up note click handlers for the header badge
497
+ setupNoteClickHandlers(_doc, listEl);
498
+ }
499
+ /**
500
+ * Find all assertions that fired at approximately the same time (within GROUP_THRESHOLD_MS)
501
+ */
502
+ function findChordSiblings(timestamp) {
503
+ return assertions.filter(a => Math.abs(a.timestamp - timestamp) <= GROUP_THRESHOLD_MS);
504
+ }
505
+ /**
506
+ * Set up hover handlers for chord triggers in the sequence view
507
+ */
508
+ function setupChordHoverHandlers(doc, listEl) {
509
+ let tooltipEl = null;
510
+ let hideTimeout = null;
511
+ const chordTriggers = listEl.querySelectorAll('.chord-trigger');
512
+ chordTriggers.forEach(trigger => {
513
+ trigger.addEventListener('mouseenter', (e) => {
514
+ const target = e.target;
515
+ const timestamp = parseInt(target.dataset.timestamp || '0', 10);
516
+ if (!timestamp)
517
+ return;
518
+ // Clear any pending hide
519
+ if (hideTimeout) {
520
+ clearTimeout(hideTimeout);
521
+ hideTimeout = null;
522
+ }
523
+ // Find all assertions in this chord
524
+ const chordMembers = findChordSiblings(timestamp);
525
+ if (chordMembers.length === 0)
526
+ return;
527
+ // Initialize audio and play the chord
528
+ initAudio();
529
+ playGroupChord(chordMembers);
530
+ // Create and show tooltip
531
+ if (tooltipEl) {
532
+ tooltipEl.remove();
533
+ }
534
+ tooltipEl = doc.createElement('div');
535
+ tooltipEl.innerHTML = renderChordTooltip(chordMembers);
536
+ const tooltip = tooltipEl.firstElementChild;
537
+ if (tooltip) {
538
+ doc.body.appendChild(tooltip);
539
+ // Position tooltip near the trigger
540
+ const rect = target.getBoundingClientRect();
541
+ tooltip.style.left = `${rect.right + 12}px`;
542
+ tooltip.style.top = `${rect.top - 8}px`;
543
+ // Adjust if off-screen
544
+ const tooltipRect = tooltip.getBoundingClientRect();
545
+ if (tooltipRect.right > window.innerWidth - 20) {
546
+ tooltip.style.left = `${rect.left - tooltipRect.width - 12}px`;
547
+ }
548
+ if (tooltipRect.bottom > window.innerHeight - 20) {
549
+ tooltip.style.top = `${window.innerHeight - tooltipRect.height - 20}px`;
550
+ }
551
+ tooltipEl = tooltip;
552
+ }
553
+ });
554
+ trigger.addEventListener('mouseleave', () => {
555
+ // Delay hiding to allow moving to tooltip
556
+ hideTimeout = setTimeout(() => {
557
+ if (tooltipEl) {
558
+ tooltipEl.remove();
559
+ tooltipEl = null;
560
+ }
561
+ }, 200);
562
+ });
563
+ });
564
+ // Also hide tooltip when clicking elsewhere
565
+ doc.addEventListener('click', () => {
566
+ if (tooltipEl) {
567
+ tooltipEl.remove();
568
+ tooltipEl = null;
569
+ }
570
+ });
571
+ }
572
+ /**
573
+ * Set up click handlers for the piano roll columns
574
+ */
575
+ function setupPianoRollHandlers(_doc, listEl) {
576
+ const pianoRoll = listEl.querySelector('.piano-roll');
577
+ if (!pianoRoll)
578
+ return;
579
+ // Parse chord data
580
+ const chordDataStr = pianoRoll.dataset.chords;
581
+ if (!chordDataStr)
582
+ return;
583
+ let chordData;
584
+ try {
585
+ chordData = JSON.parse(chordDataStr);
586
+ }
587
+ catch {
588
+ return;
589
+ }
590
+ const cells = pianoRoll.querySelectorAll('.piano-cell');
591
+ const markers = pianoRoll.querySelectorAll('.piano-time-marker');
592
+ // Helper to highlight a column
593
+ const highlightColumn = (colIdx) => {
594
+ pianoRoll.classList.add('col-hover');
595
+ cells.forEach(cell => {
596
+ const cellCol = parseInt(cell.dataset.col || '-1', 10);
597
+ cell.classList.toggle('col-active', cellCol === colIdx);
598
+ });
599
+ };
600
+ // Helper to clear column highlight
601
+ const clearHighlight = () => {
602
+ pianoRoll.classList.remove('col-hover');
603
+ cells.forEach(cell => cell.classList.remove('col-active'));
604
+ };
605
+ // Helper to play a chord
606
+ const playColumn = (colIdx) => {
607
+ if (colIdx < 0 || colIdx >= chordData.length)
608
+ return;
609
+ const chord = chordData[colIdx];
610
+ initAudio();
611
+ // Create assertion-like objects for playGroupChord
612
+ const assertionLike = chord.notes.map(n => ({
613
+ type: (n.result ? 'pass' : 'fail'),
614
+ description: n.description,
615
+ result: n.result,
616
+ timestamp: chord.timestamp,
617
+ }));
618
+ playGroupChord(assertionLike);
619
+ // Visual feedback on marker
620
+ const marker = markers[colIdx];
621
+ if (marker) {
622
+ marker.classList.add('playing');
623
+ setTimeout(() => marker.classList.remove('playing'), 300);
624
+ }
625
+ };
626
+ // Add hover and click handlers to cells
627
+ cells.forEach(cell => {
628
+ const colIdx = parseInt(cell.dataset.col || '-1', 10);
629
+ cell.addEventListener('mouseenter', () => highlightColumn(colIdx));
630
+ cell.addEventListener('mouseleave', clearHighlight);
631
+ cell.addEventListener('click', (e) => {
632
+ e.stopPropagation();
633
+ playColumn(colIdx);
634
+ });
635
+ });
636
+ // Add hover and click handlers to timeline markers
637
+ markers.forEach((marker, idx) => {
638
+ marker.addEventListener('mouseenter', () => highlightColumn(idx));
639
+ marker.addEventListener('mouseleave', clearHighlight);
640
+ marker.addEventListener('click', (e) => {
641
+ e.stopPropagation();
642
+ playColumn(idx);
643
+ });
644
+ });
645
+ }
646
+ /**
647
+ * Set up click handlers for note badges and piano bars to play sounds
648
+ */
649
+ function setupNoteClickHandlers(_doc, listEl) {
650
+ // Handle clickable note badges
651
+ const noteBadges = listEl.querySelectorAll('.note-badge.clickable');
652
+ noteBadges.forEach(badge => {
653
+ badge.addEventListener('click', (e) => {
654
+ e.stopPropagation();
655
+ const target = e.currentTarget;
656
+ const description = target.dataset.description || '';
657
+ const result = target.dataset.result === 'true';
658
+ if (!description)
659
+ return;
660
+ // Initialize audio and play the note
661
+ initAudio();
662
+ playAssertionSound({
663
+ type: result ? 'pass' : 'fail',
664
+ description,
665
+ result,
666
+ timestamp: Date.now(),
667
+ });
668
+ // Visual feedback
669
+ target.style.transform = 'scale(1.3)';
670
+ setTimeout(() => {
671
+ target.style.transform = '';
672
+ }, 150);
673
+ });
674
+ });
675
+ // Handle piano bar clicks
676
+ const pianoBars = listEl.querySelectorAll('.piano-bar');
677
+ pianoBars.forEach(bar => {
678
+ bar.addEventListener('click', (e) => {
679
+ e.stopPropagation();
680
+ const target = e.currentTarget;
681
+ const description = target.dataset.description || '';
682
+ const result = target.dataset.result === 'true';
683
+ if (!description)
684
+ return;
685
+ // Initialize audio and play the note
686
+ initAudio();
687
+ playAssertionSound({
688
+ type: result ? 'pass' : 'fail',
689
+ description,
690
+ result,
691
+ timestamp: Date.now(),
692
+ });
693
+ // Visual feedback
694
+ target.style.transform = 'translateX(8px) scale(1.02)';
695
+ setTimeout(() => {
696
+ target.style.transform = '';
697
+ }, 150);
698
+ });
699
+ });
700
+ }
701
+ //# sourceMappingURL=fullscreen.js.map