Capstone project. I'm ngl it's vibe-coded and it's only here so I can mess around with it
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add debug mouse-gaze session controls

- Add setup and in-session debug panels for skipping calibration and exiting tests
- Support mouse-derived synthetic gaze input during recording
- Persist debug/input mode metadata in exported session data

+274 -23
+43
index.css
··· 154 154 background: transparent; 155 155 } 156 156 157 + .debug-checkbox { 158 + display: inline-flex; 159 + align-items: center; 160 + gap: 8px; 161 + color: var(--ink); 162 + } 163 + 164 + .debug-checkbox input { 165 + width: 16px; 166 + height: 16px; 167 + } 168 + 157 169 #status-bar { 158 170 border-radius: 18px; 159 171 padding: 14px 18px; ··· 190 202 inset: 0; 191 203 z-index: 100; 192 204 background: #000; 205 + } 206 + 207 + .session-debug-toggle { 208 + position: absolute; 209 + top: 16px; 210 + right: 16px; 211 + z-index: 5; 212 + background: rgba(255, 250, 243, 0.92); 213 + } 214 + 215 + #session-debug-drawer { 216 + position: absolute; 217 + top: 64px; 218 + right: 16px; 219 + z-index: 5; 220 + width: min(320px, calc(100vw - 32px)); 221 + padding: 14px; 222 + border-radius: 16px; 223 + background: rgba(255, 250, 243, 0.94); 224 + border: 1px solid rgba(27, 26, 23, 0.12); 225 + box-shadow: 0 16px 36px rgba(17, 12, 10, 0.18); 226 + backdrop-filter: blur(10px); 227 + } 228 + 229 + .session-debug-header { 230 + margin-bottom: 10px; 231 + } 232 + 233 + .session-debug-actions { 234 + display: grid; 235 + gap: 10px; 193 236 } 194 237 195 238 #test-iframe,
+20
index.html
··· 47 47 <button id="debug-toggle-btn" class="debug-toggle" type="button">Debug Controls</button> 48 48 <div id="debug-panel" class="hidden"> 49 49 <button id="debug-skip-calibration" type="button">Skip Calibration</button> 50 + <label class="debug-checkbox"> 51 + <input id="debug-mouse-gaze-toggle" type="checkbox"> 52 + <span>Skip Eye Tracking (use mouse as gaze)</span> 53 + </label> 54 + <button id="debug-exit-test" type="button" disabled>Manually Exit Test</button> 50 55 <p class="debug-copy">Debug end test shortcut: <code>Shift+Escape</code></p> 51 56 </div> 52 57 </div> ··· 66 71 67 72 <section id="session-stage" class="hidden" aria-live="polite"> 68 73 <iframe id="test-iframe" class="hidden" title="Test Application"></iframe> 74 + 75 + <button id="session-debug-toggle-btn" class="session-debug-toggle hidden" type="button">Debug</button> 76 + <aside id="session-debug-drawer" class="hidden"> 77 + <div class="session-debug-header"> 78 + <strong>Debug</strong> 79 + </div> 80 + <div class="session-debug-actions"> 81 + <button id="session-debug-skip-calibration" type="button">Skip Calibration</button> 82 + <label class="debug-checkbox"> 83 + <input id="session-debug-mouse-gaze-toggle" type="checkbox"> 84 + <span>Skip Eye Tracking</span> 85 + </label> 86 + <button id="session-debug-exit-test" type="button">Manually Exit Test</button> 87 + </div> 88 + </aside> 69 89 70 90 <section id="calibration-screen" class="hidden"> 71 91 <div id="calibration-veil"></div>
+30 -1
js/gazeTracker.js
··· 58 58 export class GazeTracker { 59 59 constructor() { 60 60 this.isInitialized = false; 61 + this.inputMode = 'webgazer'; 61 62 this.mode = 'idle'; 62 63 this.stats = createEmptyStats(); 63 64 this.gazePoints = []; ··· 71 72 this.currentFixation = null; 72 73 this.lastAcceptedAt = 0; 73 74 this.logInterval = 50; 75 + } 76 + 77 + setInputMode(mode) { 78 + this.inputMode = mode; 79 + } 80 + 81 + getInputMode() { 82 + return this.inputMode; 74 83 } 75 84 76 85 async initialize() { ··· 227 236 } 228 237 } 229 238 239 + ingestSyntheticGaze(sample) { 240 + if (!this.currentMetrics || this.mode !== 'recording') { 241 + return; 242 + } 243 + 244 + const now = sample.timestamp || Date.now(); 245 + if (now - this.lastAcceptedAt < this.logInterval) { 246 + return; 247 + } 248 + this.lastAcceptedAt = now; 249 + this.stats.lastGazeX = sample.viewportX; 250 + this.stats.lastGazeY = sample.viewportY; 251 + this.acceptGazePoint({ 252 + timestamp: now, 253 + ...sample 254 + }); 255 + } 256 + 230 257 acceptGazePoint(point) { 231 258 this.stats.gazePoints += 1; 232 259 this.gazePoints.push(point); ··· 356 383 return { 357 384 stats: this.getStats(), 358 385 points: this.getGazeData(), 359 - screens: screenLookup 386 + screens: screenLookup, 387 + inputMode: this.getInputMode() 360 388 }; 361 389 } 362 390 363 391 reset() { 392 + this.inputMode = 'webgazer'; 364 393 this.mode = 'idle'; 365 394 this.stats = createEmptyStats(); 366 395 this.gazePoints = [];
+168 -22
js/main.js
··· 16 16 this.selectedApp = null; 17 17 this.captureJobs = new Map(); 18 18 this.calibrationPassed = false; 19 - this.debugOverride = false; 19 + this.calibrationSkippedByDebug = false; 20 20 this.gazeInitialized = false; 21 21 this.lastCalibrationResult = null; 22 + this.debugState = { 23 + mouseGazeMode: false, 24 + setupPanelOpen: false, 25 + sessionDrawerOpen: false 26 + }; 22 27 23 28 this.elements = { 24 29 setupShell: document.getElementById('setup-shell'), ··· 30 35 resetBtn: document.getElementById('reset-btn'), 31 36 exportBtn: document.getElementById('export-btn'), 32 37 startTestBtn: document.getElementById('start-test-btn'), 38 + 33 39 debugToggleBtn: document.getElementById('debug-toggle-btn'), 34 40 debugPanel: document.getElementById('debug-panel'), 35 41 debugSkipBtn: document.getElementById('debug-skip-calibration'), 42 + debugMouseGazeToggle: document.getElementById('debug-mouse-gaze-toggle'), 43 + debugExitBtn: document.getElementById('debug-exit-test'), 44 + 45 + sessionDebugToggleBtn: document.getElementById('session-debug-toggle-btn'), 46 + sessionDebugDrawer: document.getElementById('session-debug-drawer'), 47 + sessionDebugSkipBtn: document.getElementById('session-debug-skip-calibration'), 48 + sessionDebugMouseGazeToggle: document.getElementById('session-debug-mouse-gaze-toggle'), 49 + sessionDebugExitBtn: document.getElementById('session-debug-exit-test'), 36 50 37 51 sessionStatus: document.getElementById('session-status'), 38 52 sessionTimer: document.getElementById('session-timer'), ··· 111 125 init() { 112 126 this.bindEvents(); 113 127 this.bindCallbacks(); 128 + this.syncDebugUi(); 114 129 this.updateUiForState('idle'); 115 130 } 116 131 ··· 121 136 this.elements.exportBtn.addEventListener('click', () => this.exportData()); 122 137 this.elements.startTestBtn.addEventListener('click', () => this.beginTesting()); 123 138 this.elements.retryCalibrationBtn.addEventListener('click', () => this.retryCalibration()); 139 + 124 140 this.elements.debugToggleBtn.addEventListener('click', () => { 125 - this.elements.debugPanel.classList.toggle('hidden'); 141 + this.debugState.setupPanelOpen = !this.debugState.setupPanelOpen; 142 + this.syncDebugUi(); 126 143 }); 127 - this.elements.debugSkipBtn.addEventListener('click', () => this.skipCalibration()); 144 + this.elements.debugSkipBtn.addEventListener('click', () => this.debugSkipCalibration()); 145 + this.elements.debugMouseGazeToggle.addEventListener('change', (event) => { 146 + this.toggleMouseGazeMode(event.target.checked); 147 + }); 148 + this.elements.debugExitBtn.addEventListener('click', () => this.debugExitTest()); 149 + 150 + this.elements.sessionDebugToggleBtn.addEventListener('click', () => { 151 + this.debugState.sessionDrawerOpen = !this.debugState.sessionDrawerOpen; 152 + this.syncDebugUi(); 153 + }); 154 + this.elements.sessionDebugSkipBtn.addEventListener('click', () => this.debugSkipCalibration()); 155 + this.elements.sessionDebugMouseGazeToggle.addEventListener('change', (event) => { 156 + this.toggleMouseGazeMode(event.target.checked); 157 + }); 158 + this.elements.sessionDebugExitBtn.addEventListener('click', () => this.debugExitTest()); 159 + 128 160 this.elements.iframe.addEventListener('load', () => this.onIframeLoad()); 129 161 this.elements.calibrationTarget.addEventListener('click', () => this.calibration.handleTargetClick()); 130 162 ··· 136 168 window.addEventListener('keydown', (event) => { 137 169 if (event.shiftKey && event.key === 'Escape' && this.session.state === 'recording') { 138 170 event.preventDefault(); 139 - this.finishTest({ strategy: 'debug-shortcut' }); 171 + this.debugExitTest(); 140 172 } 141 173 }); 142 174 } ··· 150 182 this.tracker.onEvent = (event) => { 151 183 this.gazeTracker.recordInteractionEvent(event); 152 184 }; 185 + this.tracker.onMousePosition = (payload) => { 186 + if (!this.debugState.mouseGazeMode || this.session.state !== 'recording') { 187 + return; 188 + } 189 + this.gazeTracker.ingestSyntheticGaze({ 190 + timestamp: payload.timestamp, 191 + viewportX: Math.round(payload.metrics.iframeLeft + payload.clientX), 192 + viewportY: Math.round(payload.metrics.iframeTop + payload.clientY), 193 + iframeX: payload.clientX, 194 + iframeY: payload.clientY, 195 + inIframe: true, 196 + screenKey: payload.metrics.key, 197 + scrollX: payload.metrics.scrollX, 198 + scrollY: payload.metrics.scrollY, 199 + docX: payload.docX, 200 + docY: payload.docY, 201 + viewportWidth: payload.metrics.viewportWidth, 202 + viewportHeight: payload.metrics.viewportHeight, 203 + documentWidth: payload.metrics.documentWidth, 204 + documentHeight: payload.metrics.documentHeight 205 + }); 206 + }; 153 207 154 208 this.bridge.onScreenChange = (metrics) => { 155 209 this.gazeTracker.updateMetrics(metrics); ··· 166 220 this.lastCalibrationResult = result; 167 221 if (result.passed) { 168 222 this.calibrationPassed = true; 169 - this.debugOverride = false; 223 + this.calibrationSkippedByDebug = false; 170 224 this.session.setState('ready_to_start'); 171 225 } else { 172 226 this.calibrationPassed = false; ··· 190 244 }; 191 245 } 192 246 247 + toggleMouseGazeMode(enabled) { 248 + if (this.session.state === 'recording') { 249 + this.syncDebugUi(); 250 + return; 251 + } 252 + 253 + this.debugState.mouseGazeMode = enabled; 254 + this.gazeTracker.setInputMode(enabled ? 'mouse' : 'webgazer'); 255 + this.syncDebugUi(); 256 + 257 + if (enabled) { 258 + this.calibrationSkippedByDebug = true; 259 + this.calibrationPassed = false; 260 + this.lastCalibrationResult = null; 261 + if (this.gazeInitialized) { 262 + this.gazeTracker.end().catch(() => { }); 263 + this.gazeInitialized = false; 264 + } 265 + if (['calibrating', 'calibration_failed'].includes(this.session.state)) { 266 + this.session.setState('ready_to_start'); 267 + } 268 + } else if (this.session.state === 'ready_to_start' && this.calibrationSkippedByDebug) { 269 + this.calibrationSkippedByDebug = false; 270 + this.calibrationPassed = false; 271 + this.session.setState('calibrating'); 272 + this.gazeTracker.setMode('calibration'); 273 + this.calibration.start(); 274 + } 275 + } 276 + 277 + syncDebugUi() { 278 + const recording = this.session.state === 'recording'; 279 + const canSkipCalibration = this.session.state === 'calibrating'; 280 + const sessionDrawerStates = new Set(['calibrating', 'calibration_failed', 'ready_to_start', 'recording']); 281 + const drawerVisible = sessionDrawerStates.has(this.session.state); 282 + 283 + this.elements.debugPanel.classList.toggle('hidden', !this.debugState.setupPanelOpen); 284 + this.elements.sessionDebugToggleBtn.classList.toggle('hidden', !drawerVisible); 285 + this.elements.sessionDebugDrawer.classList.toggle('hidden', !drawerVisible || !this.debugState.sessionDrawerOpen); 286 + 287 + this.elements.debugMouseGazeToggle.checked = this.debugState.mouseGazeMode; 288 + this.elements.sessionDebugMouseGazeToggle.checked = this.debugState.mouseGazeMode; 289 + 290 + this.elements.debugMouseGazeToggle.disabled = recording; 291 + this.elements.sessionDebugMouseGazeToggle.disabled = recording; 292 + 293 + this.elements.debugSkipBtn.disabled = !canSkipCalibration; 294 + this.elements.sessionDebugSkipBtn.disabled = !canSkipCalibration; 295 + 296 + this.elements.debugExitBtn.disabled = !recording; 297 + this.elements.sessionDebugExitBtn.disabled = !recording; 298 + } 299 + 193 300 async loadSelectedApp() { 194 301 if (!this.selectedApp) { 195 302 window.alert('Select an app to test.'); ··· 199 306 await this.resetRuntimeOnly(); 200 307 this.session.reset(); 201 308 this.calibrationPassed = false; 202 - this.debugOverride = false; 309 + this.calibrationSkippedByDebug = false; 310 + this.lastCalibrationResult = null; 203 311 this.session.setAppName(this.selectedApp.name); 204 312 this.session.setTask(this.selectedApp.task); 205 313 this.elements.currentTaskText.textContent = this.selectedApp.task; ··· 220 328 return; 221 329 } 222 330 331 + await this.forceMetricsRefresh(); 332 + const metrics = this.bridge.getMetricsSnapshot(); 333 + this.gazeTracker.updateMetrics(metrics); 334 + this.captureScreen(metrics?.key); 335 + 336 + if (this.debugState.mouseGazeMode) { 337 + this.gazeTracker.setInputMode('mouse'); 338 + this.gazeTracker.setMode('recording'); 339 + this.calibrationPassed = false; 340 + this.calibrationSkippedByDebug = true; 341 + this.lastCalibrationResult = null; 342 + this.session.setState('ready_to_start'); 343 + return; 344 + } 345 + 223 346 const initialized = await this.gazeTracker.initialize(); 224 347 if (!initialized) { 225 348 this.failSession('WebGazer failed to initialize.'); ··· 227 350 } 228 351 229 352 this.gazeInitialized = true; 353 + this.gazeTracker.setInputMode('webgazer'); 230 354 this.session.setState('calibrating'); 231 - await this.forceMetricsRefresh(); 232 - const metrics = this.bridge.getMetricsSnapshot(); 233 - this.gazeTracker.updateMetrics(metrics); 234 355 this.gazeTracker.setMode('calibration'); 235 356 this.lastCalibrationResult = null; 236 357 this.calibration.start(); 237 - this.captureScreen(metrics?.key); 238 358 } 239 359 240 - skipCalibration() { 360 + debugSkipCalibration() { 241 361 if (this.session.state !== 'calibrating') { 242 362 return; 243 363 } 244 364 245 - this.debugOverride = true; 365 + this.calibrationSkippedByDebug = true; 246 366 this.calibrationPassed = false; 247 367 this.elements.calibrationFeedback.textContent = 'Calibration bypassed via debug override'; 248 368 this.elements.calibrationQuality.textContent = 'Debug override'; ··· 257 377 258 378 this.lastCalibrationResult = null; 259 379 this.calibrationPassed = false; 260 - this.debugOverride = false; 380 + this.calibrationSkippedByDebug = false; 261 381 this.elements.calibrationFailureSummary.textContent = 'Average error summary will appear here.'; 262 382 this.gazeTracker.setMode('calibration'); 263 383 this.session.setState('calibrating'); ··· 268 388 if (!this.selectedApp) { 269 389 return; 270 390 } 271 - if (!(this.calibrationPassed || this.debugOverride)) { 391 + if (!(this.calibrationPassed || this.calibrationSkippedByDebug)) { 272 392 this.elements.sessionMessage.textContent = 'Calibration must pass before recording can start.'; 273 393 return; 274 394 } ··· 287 407 session: this.session, 288 408 complete: (details) => this.finishTest(details) 289 409 }); 410 + } 411 + 412 + debugExitTest() { 413 + if (this.session.state !== 'recording') { 414 + return; 415 + } 416 + this.finishTest({ strategy: 'debug-manual' }); 290 417 } 291 418 292 419 async finishTest(details) { ··· 341 468 const stats = this.tracker.getStats(); 342 469 const gazeStats = this.gazeTracker.getStats(); 343 470 const screens = this.gazeTracker.getScreenRecords(); 471 + const inputMode = this.gazeTracker.getInputMode(); 344 472 345 473 this.elements.debriefTime.textContent = this.session.formatTime(this.session.elapsed); 346 474 this.elements.debriefClicks.textContent = stats.mouse.clicks.toLocaleString(); ··· 352 480 this.elements.debriefFixations.textContent = gazeStats.fixations.toLocaleString(); 353 481 this.elements.debriefFixationDuration.textContent = gazeStats.avgFixationDuration.toLocaleString(); 354 482 this.elements.sessionMessage.textContent = `Test finished via ${details?.strategy || 'manual'} completion.`; 483 + this.elements.galleryLabel.textContent = inputMode === 'mouse' 484 + ? 'Mouse-derived gaze debug session' 485 + : 'Heatmaps will appear here after a test completes.'; 355 486 356 487 await this.debriefRenderer.render(screens); 357 488 this.elements.exportBtn.disabled = false; ··· 377 508 this.session.exportSession({ 378 509 gaze: this.gazeTracker.exportData(), 379 510 interactions: this.tracker.exportData(), 380 - screens: screenRecords 511 + screens: screenRecords, 512 + debug: { 513 + mouseGazeMode: this.debugState.mouseGazeMode, 514 + calibrationSkipped: this.calibrationSkippedByDebug, 515 + inputMode: this.gazeTracker.getInputMode() 516 + } 381 517 }); 382 518 } 383 519 ··· 397 533 this.elements.debriefShell.classList.add('hidden'); 398 534 this.elements.sessionStage.classList.add('hidden'); 399 535 this.lastCalibrationResult = null; 536 + this.calibrationPassed = false; 537 + this.calibrationSkippedByDebug = false; 538 + this.debugState.sessionDrawerOpen = false; 400 539 401 540 if (this.gazeInitialized) { 402 541 await this.gazeTracker.end(); ··· 408 547 await this.resetRuntimeOnly(); 409 548 this.session.reset(); 410 549 this.selectedApp = null; 411 - this.calibrationPassed = false; 412 - this.debugOverride = false; 550 + this.debugState.mouseGazeMode = false; 551 + this.debugState.setupPanelOpen = false; 413 552 this.elements.appSelect.value = ''; 414 553 this.elements.currentTaskText.textContent = 'None'; 415 554 this.elements.iframe.src = ''; 416 555 this.elements.iframe.classList.add('hidden'); 417 556 this.elements.iframePlaceholder.classList.remove('hidden'); 418 557 this.elements.sessionMessage.textContent = 'Select an app to begin.'; 419 - this.elements.debugPanel.classList.add('hidden'); 420 558 document.body.classList.remove('session-active'); 559 + this.syncDebugUi(); 421 560 } 422 561 423 562 failSession(message) { 424 563 this.session.setState('error', { errorMessage: message }); 425 564 this.elements.sessionMessage.textContent = message; 426 565 document.body.classList.remove('session-active'); 566 + this.syncDebugUi(); 427 567 } 428 568 429 569 async forceMetricsRefresh() { ··· 483 623 break; 484 624 } 485 625 case 'ready_to_start': 486 - this.elements.sessionMessage.textContent = this.debugOverride 487 - ? 'Calibration skipped in debug mode. Recording can start.' 488 - : 'Calibration passed. Start the fullscreen test when ready.'; 626 + this.elements.sessionMessage.textContent = this.debugState.mouseGazeMode 627 + ? 'Eye tracking disabled in debug mode. Mouse pointer will be used as gaze.' 628 + : this.calibrationSkippedByDebug 629 + ? 'Calibration skipped in debug mode. Recording can start.' 630 + : 'Calibration passed. Start the fullscreen test when ready.'; 489 631 this.elements.startOverlayTask.textContent = this.selectedApp?.task || 'Complete the assigned task.'; 490 632 break; 491 633 case 'recording': 492 - this.elements.sessionMessage.textContent = 'Recording fullscreen session.'; 634 + this.elements.sessionMessage.textContent = this.debugState.mouseGazeMode 635 + ? 'Recording fullscreen session with mouse-derived gaze.' 636 + : 'Recording fullscreen session.'; 493 637 break; 494 638 case 'finishing': 495 639 this.elements.sessionMessage.textContent = 'Finalizing screenshots and rendering debrief...'; ··· 504 648 document.body.classList.remove('session-active'); 505 649 break; 506 650 } 651 + 652 + this.syncDebugUi(); 507 653 } 508 654 } 509 655
+13
js/tracker.js
··· 34 34 this.lastMouseTime = null; 35 35 this.velocities = []; 36 36 this.onEvent = null; 37 + this.onMousePosition = null; 37 38 } 38 39 39 40 attach(bridge) { ··· 80 81 this.lastMousePos = null; 81 82 this.lastMouseTime = null; 82 83 this.velocities = []; 84 + this.onMousePosition = null; 83 85 } 84 86 85 87 handleMouseMove(event) { ··· 117 119 this.stats.mouse.y = docY; 118 120 this.stats.mouse.movements += 1; 119 121 this.stats.totalEvents += 1; 122 + 123 + if (this.onMousePosition) { 124 + this.onMousePosition({ 125 + timestamp: now, 126 + clientX: iframeX, 127 + clientY: iframeY, 128 + docX, 129 + docY, 130 + metrics 131 + }); 132 + } 120 133 121 134 if (this.stats.mouse.movements % 12 === 0) { 122 135 this.logEvent('mouse', 'mousemove', event, metrics, { docX, docY });