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.

Documentation Vol. 1

+76 -1
.codex

This is a binary file and will not be displayed.

+3
js/analysisElements.js
··· 22 22 return exact; 23 23 } 24 24 } 25 + // Imported sessions may lack fingerprints, so fall back from identity to geometry. 25 26 const containing = elements.find((element) => pointInRect(click, element.rect)); 26 27 if (containing) { 27 28 return containing; ··· 67 68 const metrics = []; 68 69 69 70 screens.forEach((screen) => { 71 + // Limit candidates so broad pages do not let incidental DOM nodes dominate analysis time. 70 72 const elements = screen.elementSnapshots 71 73 .filter((element) => element.visible !== false && rectArea(element.rect) > 0 && isActionable(element)) 72 74 .slice(0, 120); ··· 92 94 }); 93 95 94 96 elements.forEach((element) => { 97 + // Near-rectangle matching tolerates webcam and pointer imprecision around small controls. 95 98 const nearbyFixations = screenFixations.filter((fixation) => { 96 99 const point = { docX: fixation.x, docY: fixation.y }; 97 100 return pointInRect(point, element.rect) || distanceToRect(point, element.rect) <= NEAR_DISTANCE;
+5 -1
js/analysisFindings.js
··· 8 8 } 9 9 10 10 function priority({ severity, confidence, scope, recurrence = 0, affectedTime = 0, dataQualityPenalty = 0 }) { 11 + // Priority is a sorting aid, not a statistical probability. 11 12 return clamp(Math.round( 12 13 (SEVERITY_WEIGHT[severity] || 0) + 13 14 (CONFIDENCE_WEIGHT[confidence] || 0) + ··· 27 28 } 28 29 29 30 function qualityPenalty(confidence) { 31 + // Low-confidence sessions should still surface signals, but they should rank below cleaner evidence. 30 32 if (confidence.score < 45) { 31 33 return 25; 32 34 } ··· 49 51 const dataPenalty = qualityPenalty(confidence); 50 52 const fastAction = hasFastAction(globalMetrics); 51 53 54 + // Fast first action suppresses speculative gaze-only friction later in this function. 52 55 if (fastAction) { 53 56 findings.push(finding({ 54 57 id: 'fast-first-action', ··· 130 133 } 131 134 132 135 elementMetrics.forEach((element) => { 136 + // Element-level findings are intentionally stricter because they imply a specific UI target. 133 137 if (element.repeatedClickCount >= 1) { 134 138 findings.push(finding({ 135 139 id: 'element-repeated-clicks', ··· 375 379 } 376 380 }); 377 381 382 + // Quality warnings are rendered through the same finding UI so limitations stay visible in exports and reports. 378 383 warnings.forEach((warning) => { 379 384 findings.push(finding({ 380 385 id: `quality-${warning.code}`, ··· 444 449 confidenceScore: confidence.score 445 450 }; 446 451 } 447 -
+7
js/analysisMetrics.js
··· 41 41 const fixations = []; 42 42 let current = null; 43 43 44 + // Fixations are approximated as one moving centroid per screen until gaze drifts too far away. 44 45 const finalize = (endTime) => { 45 46 if (!current) { 46 47 return; ··· 116 117 const buckets = new Map(); 117 118 const cols = 4; 118 119 const rows = 4; 120 + // A coarse grid is enough to describe spread without pretending pixel-level precision. 119 121 points.forEach((point) => { 120 122 const col = clamp(Math.floor((point.docX / width) * cols), 0, cols - 1); 121 123 const row = clamp(Math.floor((point.docY / height) * rows), 0, rows - 1); ··· 199 201 if (current.screenKey !== previous.screenKey) { 200 202 continue; 201 203 } 204 + // Tight time and distance bounds catch repeated attempts without labeling normal double-clicks everywhere. 202 205 const withinTime = current.timestamp - previous.timestamp <= 1200; 203 206 const withinDistance = Math.hypot((current.docX || 0) - (previous.docX || 0), (current.docY || 0) - (previous.docY || 0)) <= 36; 204 207 if (withinTime && withinDistance) { ··· 212 215 const byScreen = groupBy(fixations, (fixation) => fixation.screenKey); 213 216 const latencies = clicks.map((click) => { 214 217 const candidates = byScreen.get(click.screenKey) || []; 218 + // Walk backwards so the closest prior look wins instead of an older fixation in the same area. 215 219 for (let index = candidates.length - 1; index >= 0; index -= 1) { 216 220 const fixation = candidates[index]; 217 221 if (fixation.endTime > click.timestamp) { ··· 296 300 if (segmentDistance < 12 && dt > 1200) { 297 301 idleHesitationWindows += 1; 298 302 } 303 + // Movement near a click is intentional navigation; movement away from actions is treated as possible searching. 299 304 const nearAction = clicks.some((click) => Math.abs(click.timestamp - current.timestamp) <= 1200); 300 305 if (!nearAction) { 301 306 deadMovement += segmentDistance; ··· 316 321 if (leading.length < 2) { 317 322 return null; 318 323 } 324 + // Ratio above 1 means the pointer path was less direct than a straight line to the click. 319 325 const path = computeScanpathLength(leading); 320 326 const direct = Math.hypot(leading[0].docX - (click.docX || 0), leading[0].docY - (click.docY || 0)); 321 327 return direct > 0 ? path / direct : 1; ··· 369 375 if (event.type !== 'scroll') { 370 376 return true; 371 377 } 378 + // Scroll can be observed on both window and document; identical snapshots should count once. 372 379 const key = [ 373 380 event.type, 374 381 event.timestamp,
+3
js/analysisRenderer.js
··· 39 39 } 40 40 41 41 return findings.map((finding) => { 42 + // All finding text is escaped here because analysis can include imported session labels. 42 43 const evidence = (finding.evidence || []).map((item) => `<li>${formatEvidence(item)}</li>`).join(''); 43 44 const target = finding.elementFingerprint 44 45 ? `<p class="analysis-target">Element: ${escapeHtml(finding.elementFingerprint)}</p>` ··· 169 170 170 171 renderCohort({ cohortAnalysis, importErrors = [] }) { 171 172 const warnings = [...cohortAnalysis.warnings]; 173 + // Import errors become quality warnings so partial cohort imports remain transparent. 172 174 importErrors.forEach((error) => { 173 175 warnings.push({ 174 176 code: `import-failed-${error.fileName}`, ··· 263 265 } 264 266 265 267 renderSessionComparison({ cohortAnalysis }) { 268 + // Live same-app comparisons append to the single-session summary instead of replacing it. 266 269 const repeated = cohortAnalysis.repeatedFindings.slice(0, 3).map((item) => ` 267 270 <li>${escapeHtml(item.title)} appeared in ${escapeHtml(item.sessionCount)} sessions (${escapeHtml(item.share)}%).</li> 268 271 `).join('');
+3
js/calibration.js
··· 72 72 await new Promise((resolve) => window.setTimeout(resolve, 220)); 73 73 74 74 const target = this.getTargetPixelPosition(point); 75 + // Score only samples collected since this point became active; earlier calibration noise is ignored. 75 76 const samples = this.gazeTracker.getCalibrationSamples( 76 77 this.currentPointStart, 77 78 Date.now() + this.sampleWindowMs 78 79 ); 80 + // Off-iframe samples are not useful because target positions are measured in the UXET viewport. 79 81 const inIframeSamples = samples.filter((sample) => sample.inIframe); 80 82 const error = inIframeSamples.length 81 83 ? Math.round( ··· 140 142 getTargetPixelPosition(point) { 141 143 const viewport = this.getViewportRect(); 142 144 const insets = this.getSafeAreaInsets(); 145 + // Insets keep edge targets away from the HUD and browser chrome-sensitive corners. 143 146 const width = Math.max(0, viewport.width - insets.left - insets.right); 144 147 const height = Math.max(0, viewport.height - insets.top - insets.bottom); 145 148
+4
js/cohortAnalyzer.js
··· 13 13 const screen = finding.screenKey 14 14 ? artifact.screens.find((item) => item.key === finding.screenKey) 15 15 : null; 16 + // Group by finding type plus target context so unrelated screens do not look like repeated evidence. 16 17 return [ 17 18 finding.id, 18 19 finding.elementFingerprint || '', ··· 48 49 const appNames = unique(artifacts.map((artifact) => artifact.session.appName)); 49 50 const tasks = unique(artifacts.map((artifact) => artifact.session.task)); 50 51 const mixedAppOrTask = appNames.length > 1 || unique(tasks.map(normalizeText)).length > 1; 52 + // Small cohorts still need at least directional repetition without requiring a large sample size. 51 53 const threshold = Math.min(2, Math.max(1, Math.ceil(artifacts.length * 0.4))); 52 54 const findingGroups = new Map(); 53 55 const elementGroups = new Map(); ··· 109 111 totalRepeatedClicks: items.reduce((sum, item) => sum + item.element.repeatedClickCount, 0) 110 112 }; 111 113 }) 114 + // Only surface repeated element patterns that include some friction-like signal. 112 115 .filter((pattern) => pattern.totalRepeatedClicks > 0 || pattern.medianLookBeforeClickRate < 60 || pattern.medianPostClickConfirmationRate < 60) 113 116 .sort((a, b) => b.totalRepeatedClicks - a.totalRepeatedClicks || a.medianLookBeforeClickRate - b.medianLookBeforeClickRate); 114 117 ··· 127 130 attentionFrictionScore: analysis.globalMetrics.attentionFrictionScore, 128 131 interactionFrictionScore: analysis.globalMetrics.interactionFrictionScore, 129 132 confidenceScore: analysis.confidence.score, 133 + // Pick one plain-language reason so the report stays scannable. 130 134 reason: analysis.globalMetrics.timeToFirstAction >= 5000 131 135 ? 'First action was delayed after pre-action exploration.' 132 136 : analysis.globalMetrics.duration > medianDuration * 1.8
+4
js/debriefRenderer.js
··· 8 8 } 9 9 10 10 function heatmapColor(t) { 11 + // Blue-to-red makes low-intensity areas visible without overpowering the screenshot. 11 12 if (t < 0.25) { 12 13 const f = t / 0.25; 13 14 return [0, Math.round(f * 180), Math.round(200 + f * 55)]; ··· 42 43 ctx.drawImage(image, 0, 0, width, height); 43 44 } 44 45 46 + // Build intensity on a separate canvas so colorization can normalize against the actual max alpha. 45 47 const heatCanvas = document.createElement('canvas'); 46 48 heatCanvas.width = width; 47 49 heatCanvas.height = height; ··· 68 70 69 71 for (let index = 0; index < data.length; index += 4) { 70 72 const intensity = data[index + 3] / maxAlpha; 73 + // Drop the faint edge of each radial gradient to keep the overlay readable. 71 74 if (intensity < 0.02) { 72 75 data[index + 3] = 0; 73 76 continue; ··· 114 117 return; 115 118 } 116 119 120 + // A screen is useful if it has either a screenshot or enough gaze data to explain what is missing. 117 121 const renderableScreens = screens.filter((screen) => { 118 122 const hasScreenshot = screen.screenshot.status === 'ready' && screen.screenshot.dataUrl; 119 123 const usablePoints = getUsablePoints(screen);
+7
js/gazeTracker.js
··· 95 95 96 96 try { 97 97 window.saveDataAcrossSessions = false; 98 + // WebGazer loads MediaPipe assets lazily, so keep the path explicit for local static serving. 98 99 webgazer.params.faceMeshSolutionPath = 'https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh'; 99 100 webgazer 100 101 .setRegression('ridge') ··· 204 205 inIframe: false 205 206 }; 206 207 208 + // Raw samples are kept during calibration even before they become recorded gaze points. 207 209 this.rawSamples.push(sample); 208 210 if (this.rawSamples.length > 400) { 209 211 this.rawSamples.shift(); ··· 216 218 this.stats.lastGazeX = sample.viewportX; 217 219 this.stats.lastGazeY = sample.viewportY; 218 220 221 + // WebGazer reports parent viewport coordinates; analysis needs iframe document coordinates. 219 222 const metrics = this.currentMetrics; 220 223 const iframeX = sample.viewportX - metrics.iframeLeft; 221 224 const iframeY = sample.viewportY - metrics.iframeTop; ··· 242 245 243 246 this.rawSamples[this.rawSamples.length - 1] = fullSample; 244 247 248 + // Throttle accepted points to keep exports and heatmaps usable on long sessions. 245 249 if (this.mode === 'recording' && now - this.lastAcceptedAt >= this.logInterval) { 246 250 this.lastAcceptedAt = now; 247 251 this.acceptGazePoint(fullSample); ··· 308 312 const distance = Math.hypot(dx, dy); 309 313 const sameScreen = point.screenKey === this.currentFixation.screenKey; 310 314 315 + // Use a running centroid so fixation centers are less sensitive to single noisy samples. 311 316 if (sameScreen && distance <= this.fixationThreshold) { 312 317 this.currentFixation.pointCount += 1; 313 318 this.currentFixation.x = ··· 439 444 if (typeof webgazer !== 'undefined') { 440 445 try { 441 446 webgazer.pause(); 447 + // WebGazer does not consistently release camera tracks across browsers. 442 448 const videoElement = webgazer.getVideoElement ? webgazer.getVideoElement() : null; 443 449 const stream = videoElement?.srcObject || webgazer.params?.videoStream || null; 444 450 if (stream?.getTracks) { ··· 449 455 console.warn('[GazeTracker] Failed to end WebGazer cleanly:', error); 450 456 } 451 457 } 458 + // Clean up hidden WebGazer video nodes so repeated tests do not accumulate camera elements. 452 459 document.querySelectorAll('#webgazerVideoContainer, video').forEach((element) => { 453 460 if (element.id === 'webgazerVideoContainer' || element.dataset?.webgazerVideoFeed) { 454 461 element.remove();
+5
js/iframeBridge.js
··· 47 47 const restoreFns = []; 48 48 49 49 images.forEach((image) => { 50 + // Full-page screenshots need lazy images loaded before html2canvas clones the document. 50 51 if (image.loading === 'lazy') { 51 52 const previousLoading = image.loading; 52 53 image.loading = 'eager'; ··· 126 127 methods.forEach((methodName) => { 127 128 const original = history[methodName].bind(history); 128 129 history[methodName] = (...args) => { 130 + // SPA route changes do not trigger iframe load, so patch history to refresh screen keys. 129 131 const result = original(...args); 130 132 this.refreshMetrics(true); 131 133 this._stableNotifier(); ··· 196 198 const docEl = doc.documentElement; 197 199 const body = doc.body; 198 200 const rect = this.iframe.getBoundingClientRect(); 201 + // The screen key intentionally follows URL path/search/hash instead of DOM title. 199 202 const key = `${win.location.pathname}${win.location.search}${win.location.hash}`; 200 203 const metrics = { 201 204 key, ··· 326 329 } 327 330 328 331 const doc = this.iframeDocument; 332 + // Capture semantic controls plus app-specific targets without requiring test apps to add UXET hooks. 329 333 const selector = 'a, button, input, select, textarea, label, [role], [data-uxet], [data-id], [onclick], [tabindex]'; 330 334 const candidateSet = new Set(Array.from(doc.querySelectorAll(selector))); 331 335 Array.from(doc.body?.querySelectorAll('*') || []).forEach((element) => { ··· 344 348 const clickable = element.matches(selector) || style.cursor === 'pointer'; 345 349 const ancestor = element.closest('[data-id], [role], a, button, [onclick], [tabindex]'); 346 350 return { 351 + // Fingerprints are stable enough for a session, but not treated as permanent DOM IDs. 347 352 fingerprint: [ 348 353 element.tagName.toLowerCase(), 349 354 element.id || '',
+1
js/importer.js
··· 25 25 const results = []; 26 26 const errors = []; 27 27 28 + // Partial success is useful during cohort imports: valid sessions can still be analyzed. 28 29 for (const file of Array.from(files || [])) { 29 30 try { 30 31 results.push({
+9
js/main.js
··· 233 233 if (!this.debugState.mouseGazeMode || this.session.state !== 'recording') { 234 234 return; 235 235 } 236 + // Mouse-as-gaze uses the same document coordinate shape as WebGazer samples. 236 237 this.gazeTracker.ingestSyntheticGaze({ 237 238 timestamp: payload.timestamp, 238 239 viewportX: Math.round(payload.metrics.iframeLeft + payload.clientX), ··· 282 283 this.elements.appSelect.innerHTML = '<option value="">Loading apps...</option>'; 283 284 284 285 try { 286 + // App metadata is runtime-discovered so index.html does not need hard-coded test targets. 285 287 const { apps, errors } = await discoverTestableApps(); 286 288 this.availableApps = apps; 287 289 this.elements.appSelect.innerHTML = [ ··· 351 353 this.syncDebugUi(); 352 354 353 355 if (enabled) { 356 + // Mouse mode is a deliberate calibration bypass, not a passed eye-tracking calibration. 354 357 this.calibrationSkippedByDebug = true; 355 358 this.calibrationPassed = false; 356 359 this.lastCalibrationResult = null; ··· 425 428 return; 426 429 } 427 430 431 + // Wait for layout to settle before converting viewport gaze into iframe document coordinates. 428 432 await this.forceMetricsRefresh(); 429 433 const metrics = this.bridge.getMetricsSnapshot(); 430 434 this.gazeTracker.updateMetrics(metrics); ··· 548 552 return this.captureJobs.get(screenKey); 549 553 } 550 554 555 + // Screenshot capture is async and expensive; share the same promise for repeated stable events. 551 556 const job = (async () => { 552 557 try { 553 558 this.gazeTracker.setElementSnapshots(screenKey, this.bridge.captureElementSnapshots()); ··· 629 634 630 635 this.analysisRenderer.render({ artifact, analysis }); 631 636 const sameAppHistory = this.getSameAppHistory(); 637 + // Repeated live runs are treated as a temporary same-app cohort until reset/reload. 632 638 if (artifact.source === 'live' && sameAppHistory.length >= 2) { 633 639 const cohortAnalysis = analyzeSessionCohort( 634 640 sameAppHistory.map((entry) => entry.artifact), ··· 756 762 if (results.length > 1) { 757 763 const artifacts = results.map((result) => result.artifact); 758 764 const analyses = artifacts.map((artifact) => analyzeSessionArtifact(artifact)); 765 + // Multi-file import is cohort mode; there is no single heatmap gallery to render. 759 766 const cohortAnalysis = analyzeSessionCohort(artifacts, { analyses }); 760 767 this.latestArtifact = null; 761 768 this.latestAnalysis = null; ··· 912 919 } 913 920 914 921 async forceMetricsRefresh() { 922 + // Two animation frames lets iframe layout and scroll metrics settle after load or resize. 915 923 await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); 916 924 const metrics = this.bridge.refreshMetrics(false); 917 925 if (metrics) { ··· 931 939 } 932 940 933 941 updateUiForState(state, details = {}) { 942 + // The session stage hides the tested app until calibration is complete and recording starts. 934 943 const sessionStates = new Set(['calibrating', 'calibration_failed', 'ready_to_start', 'recording', 'finishing']); 935 944 const sessionActive = sessionStates.has(state); 936 945
+1
js/session.js
··· 133 133 } 134 134 135 135 exportSession(payload) { 136 + // Kept as a fallback for older call sites; the main export path writes schema v3. 136 137 const data = { 137 138 schemaVersion: '2', 138 139 session: this.getMetadata(),
+10
js/sessionAnalyzer.js
··· 97 97 function buildConfidence(artifact, warnings) { 98 98 let score = 100; 99 99 const reasons = []; 100 + // Confidence is intentionally conservative: each data-quality issue reduces trust in all downstream claims. 100 101 warnings.forEach((warning) => { 101 102 if (warning.severity === 'high') { 102 103 score -= 20; ··· 134 135 } 135 136 }); 136 137 138 + // Revisiting any prior screen is treated as backtracking, even if the route was not strictly browser history. 137 139 let backtracks = 0; 138 140 const seen = new Set(); 139 141 screenSequence.forEach((key) => { ··· 148 150 function computeRageClickCandidates(clicks) { 149 151 const candidates = []; 150 152 for (let index = 2; index < clicks.length; index += 1) { 153 + // Three close clicks inside 1.8s is a conservative signal of repeated failed activation. 151 154 const group = [clicks[index - 2], clicks[index - 1], clicks[index]]; 152 155 const sameScreen = group.every((click) => click.screenKey === group[0].screenKey); 153 156 const withinTime = group[2].timestamp - group[0].timestamp <= 1800; ··· 176 179 } 177 180 178 181 function computeAnalysisStartTime({ points, events, fixations }) { 182 + // Imported legacy sessions can be missing one or more streams, so start from the earliest available evidence. 179 183 const candidates = [ 180 184 points[0]?.timestamp, 181 185 events[0]?.timestamp, ··· 191 195 ])); 192 196 const seen = new Set(); 193 197 198 + // Count distinct AOIs inspected before acting, not repeated looks at the same control. 194 199 fixations.forEach((fixation) => { 195 200 const elements = elementsByScreen.get(fixation.screenKey) || []; 196 201 const point = { docX: fixation.x, docY: fixation.y }; ··· 229 234 const preActionScrollDepth = computeScrollDepth(preActionEvents, artifact.screens); 230 235 const preActionScanpathLength = computeScanpathLength(preActionPoints); 231 236 const timeToFirstAction = actionTime ? Math.max(0, actionTime - analysisStartTime) : 0; 237 + // This blends time, spread, element coverage, and scrolling into one directional exploration score. 232 238 const preActionExplorationScore = clamp(Math.round( 233 239 (preActionUniqueZones * 7) + 234 240 (preActionUniqueElements * 6) + ··· 253 259 } 254 260 255 261 function classifyBehavior({ globalMetrics, clicks, confidence }) { 262 + // The outcome label gates findings so a quick successful path is not over-explained as friction. 256 263 if (confidence.score < 35 || (!globalMetrics.totalFixations && !globalMetrics.totalClicks)) { 257 264 return 'inconclusive'; 258 265 } ··· 300 307 const timeToFirstFixation = firstFixation 301 308 ? Math.max(0, firstFixation.startTime - analysisStartTime) 302 309 : 0; 310 + // Screen friction is intentionally screen-local; it should not override the session-level outcome by itself. 303 311 const frictionScore = clamp(Math.round( 304 312 (gazeEntropy * 14) + 305 313 (revisitCount * 8) + ··· 345 353 const mouseTrace = [...artifact.interactions.mouseTrace].sort((a, b) => a.timestamp - b.timestamp); 346 354 const clicks = events.filter((event) => event.type === 'click'); 347 355 const scrolls = events.filter((event) => event.type === 'scroll'); 356 + // Live schema v3 exports include fixations; legacy imports can still be analyzed by deriving them. 348 357 const fixations = artifact.gaze.fixations?.length 349 358 ? [...artifact.gaze.fixations].sort((a, b) => a.startTime - b.startTime) 350 359 : detectFixations(points); ··· 360 369 const lookThenActRate = computeLookThenActRate(clicks, fixations); 361 370 const preActionMetrics = computePreActionMetrics({ artifact, points, fixations, events, analysisStartTime }); 362 371 const timeToFirstMeaningfulAction = preActionMetrics.timeToFirstAction; 372 + // These scores are heuristic signals for ranking findings, not validated clinical UX metrics. 363 373 const attentionFrictionScore = clamp(Math.round( 364 374 Math.min(preActionMetrics.timeToFirstAction / 160, 55) + 365 375 (preActionMetrics.preActionUniqueZones * 4) +
+6
js/sessionArtifact.js
··· 160 160 width: asNumber(documentInfo.width, asNumber(source.documentWidth)), 161 161 height: asNumber(documentInfo.height, asNumber(source.documentHeight)) 162 162 }, 163 + // Older exports stored screenshot metadata as flat fields; v3 stores it as an object. 163 164 screenshot: { 164 165 dataUrl: typeof screenshot.dataUrl === 'string' ? screenshot.dataUrl : null, 165 166 width: asNumber(screenshot.width, asNumber(source.screenshotWidth)), ··· 199 200 if (map.has(screenKey)) { 200 201 return map.get(screenKey); 201 202 } 203 + // Legacy exports can have points/events but no screen records; synthesize a minimal screen. 202 204 const inferred = normalizeScreenRecord({ key: screenKey, title: screenKey }); 203 205 map.set(screenKey, inferred); 204 206 return inferred; ··· 216 218 } 217 219 218 220 function buildFidelity({ screens, mouseTrace, points, debug }) { 221 + // Fidelity flags let analysis explain missing evidence instead of silently lowering output quality. 219 222 return { 220 223 hasScreenshots: screens.some((screen) => screen.screenshot.status === 'ready' && screen.screenshot.dataUrl), 221 224 hasMouseTrace: mouseTrace.length > 0, ··· 238 241 const interactionEvents = sortByTimestamp(asArray(interactions?.events).map(normalizeInteractionEvent)); 239 242 const mouseTrace = sortByTimestamp(asArray(interactions?.mouseTrace).map(normalizeMouseTracePoint)); 240 243 const fixations = sortByTimestamp(asArray(gaze?.fixations).map(normalizeFixation), 'startTime'); 244 + // Normalize live data through the same path as imports so the analyzer sees one schema. 241 245 const screens = enrichScreensWithPoints( 242 246 asArray(screenRecords).map(normalizeScreenRecord), 243 247 gazePoints, ··· 286 290 const gaze = asObject(source.gaze); 287 291 const interactions = asObject(source.interactions); 288 292 const analysis = asObject(source.analysis); 293 + // Some schema-v3 files may contain only embedded analysis; mark them so confidence reflects that. 289 294 const analysisContext = { 290 295 ...asObject(source.analysisContext), 291 296 embeddedAnalysisOnly: Boolean(schemaVersion === '3' && source.analysis && (!source.gaze || !source.interactions)) ··· 299 304 const fixations = sortByTimestamp(asArray(source.fixations || gaze.fixations).map(normalizeFixation), 'startTime'); 300 305 301 306 let screens = []; 307 + // Accept all historical screen shapes UXET has exported. 302 308 if (Array.isArray(importedScreenRecords)) { 303 309 screens = importedScreenRecords.map(normalizeScreenRecord); 304 310 } else if (Array.isArray(importedScreens)) {
+4
js/testableApps.js
··· 6 6 return null; 7 7 } 8 8 9 + // Directory-listing links are usually relative to /testable-apps/, not to the UXET page. 9 10 const rootUrl = new URL(TESTABLE_APPS_ROOT, window.location.href); 10 11 const url = new URL(href, rootUrl.href); 11 12 if (!url.href.startsWith(rootUrl.href) || !url.pathname.endsWith('/')) { ··· 22 23 } 23 24 24 25 function parseDirectoryListing(html) { 26 + // This intentionally relies on the simple HTML index produced by python -m http.server. 25 27 const document = new DOMParser().parseFromString(html, 'text/html'); 26 28 return Array.from(document.querySelectorAll('a[href]')) 27 29 .map((link) => normalizeDirectoryHref(link.getAttribute('href'))) ··· 53 55 } 54 56 55 57 export async function discoverTestableApps() { 58 + // Static sites cannot read local folders directly; the local HTTP directory index is the discovery API. 56 59 const response = await fetch(TESTABLE_APPS_ROOT, { cache: 'no-store' }); 57 60 if (!response.ok) { 58 61 throw new Error(`Failed to inspect ${TESTABLE_APPS_ROOT}: ${response.status}`); ··· 63 66 throw new Error(`No app folders were found in ${TESTABLE_APPS_ROOT}.`); 64 67 } 65 68 69 + // One bad app.json should not hide every other valid app from the tester. 66 70 const results = await Promise.allSettled(directories.map(async (directoryName) => { 67 71 const metadata = await fetchJson(`${TESTABLE_APPS_ROOT}${encodeURIComponent(directoryName)}/${APP_METADATA_FILE}`); 68 72 return normalizeMetadata(directoryName, metadata);
+3
js/tracker.js
··· 135 135 }); 136 136 } 137 137 138 + // Dense mouse trace feeds movement metrics; the event log below stays lower-volume. 138 139 if (now - this.lastTraceAt >= this.mouseTraceInterval) { 139 140 const velocity = this.velocities.length ? this.velocities[this.velocities.length - 1] : 0; 140 141 this.mouseTrace.push({ ··· 176 177 target?.innerText?.trim?.()?.slice(0, 80) || 177 178 target?.value || 178 179 ''; 180 + // Fingerprints bridge click events back to element snapshots when geometry is ambiguous. 179 181 const clickTargetFingerprint = [ 180 182 tagName, 181 183 target?.id || '', ··· 185 187 ].filter(Boolean).join('|'); 186 188 const targetPath = []; 187 189 let cursor = target; 190 + // A short ancestor path helps debug delegated click handlers without exporting the whole DOM. 188 191 while (cursor && targetPath.length < 4 && cursor !== this.bridge.iframeDocument.body) { 189 192 const rect = cursor.getBoundingClientRect?.(); 190 193 const label = cursor.getAttribute?.('aria-label') ||
+1
js/winConditions.js
··· 4 4 start({ bridge, complete, session }) { 5 5 this.stop(); 6 6 this.handler = (event) => { 7 + // Only the active iframe is allowed to complete the task. 7 8 if (session.state !== 'recording') { 8 9 return; 9 10 }