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.

Merge pull request #9 from chriskalos/fix/documentation

Documentation Fixes

authored by

Chris and committed by
GitHub
2bced150 6176c32c

+751 -1
+27
js/analysisElements.js
··· 3 3 const NEAR_DISTANCE = 48; 4 4 const ACTIONABLE_TAGS = new Set(['a', 'button', 'input', 'select', 'textarea', 'label']); 5 5 6 + /** 7 + * Computes a rectangle's non-negative area. 8 + */ 6 9 function rectArea(rect) { 7 10 return Math.max(0, rect?.width || 0) * Math.max(0, rect?.height || 0); 8 11 } 9 12 13 + /** 14 + * Decides whether a captured element is meaningful for element-level UX analysis. 15 + */ 10 16 function isActionable(element) { 11 17 return ACTIONABLE_TAGS.has(element.tag) || Boolean(element.role) || Boolean(element.fingerprint); 12 18 } 13 19 20 + /** 21 + * Selects the most readable label available for an element snapshot. 22 + */ 14 23 function labelFor(element) { 15 24 return element.label || element.text || element.fingerprint || `${element.tag || 'element'}`; 16 25 } 17 26 27 + /** 28 + * Matches a click to the most likely captured element. 29 + */ 18 30 function findElementForClick(click, elements) { 19 31 if (click.clickTargetFingerprint) { 20 32 const exact = elements.find((element) => element.fingerprint === click.clickTargetFingerprint); ··· 22 34 return exact; 23 35 } 24 36 } 37 + // Imported sessions may lack fingerprints, so fall back from identity to geometry. 25 38 const containing = elements.find((element) => pointInRect(click, element.rect)); 26 39 if (containing) { 27 40 return containing; ··· 32 45 .sort((a, b) => a.distance - b.distance)[0]?.element || null; 33 46 } 34 47 48 + /** 49 + * Counts close repeated clicks within one element's click stream. 50 + */ 35 51 function repeatedClickCount(clicks) { 36 52 let count = 0; 37 53 for (let index = 1; index < clicks.length; index += 1) { ··· 45 61 return count; 46 62 } 47 63 64 + /** 65 + * Checks whether the user looked near a click target before clicking. 66 + */ 48 67 function hasLookBeforeClick(click, fixations) { 49 68 return fixations.some((fixation) => 50 69 fixation.endTime <= click.timestamp && ··· 53 72 ); 54 73 } 55 74 75 + /** 76 + * Checks whether gaze returned near a clicked target after the action. 77 + */ 56 78 function hasPostClickConfirmation(click, fixations) { 57 79 return fixations.some((fixation) => 58 80 fixation.startTime >= click.timestamp && ··· 61 83 ); 62 84 } 63 85 86 + /** 87 + * Builds per-element interaction and attention metrics from screen snapshots. 88 + */ 64 89 export function buildElementMetrics({ screens, events, fixations }) { 65 90 const eventsByScreen = groupBy(events, (event) => event.screenKey); 66 91 const fixationsByScreen = groupBy(fixations, (fixation) => fixation.screenKey); 67 92 const metrics = []; 68 93 69 94 screens.forEach((screen) => { 95 + // Limit candidates so broad pages do not let incidental DOM nodes dominate analysis time. 70 96 const elements = screen.elementSnapshots 71 97 .filter((element) => element.visible !== false && rectArea(element.rect) > 0 && isActionable(element)) 72 98 .slice(0, 120); ··· 92 118 }); 93 119 94 120 elements.forEach((element) => { 121 + // Near-rectangle matching tolerates webcam and pointer imprecision around small controls. 95 122 const nearbyFixations = screenFixations.filter((fixation) => { 96 123 const point = { docX: fixation.x, docY: fixation.y }; 97 124 return pointInRect(point, element.rect) || distanceToRect(point, element.rect) <= NEAR_DISTANCE;
+29 -1
js/analysisFindings.js
··· 3 3 const SEVERITY_WEIGHT = { high: 35, moderate: 22, info: 10 }; 4 4 const CONFIDENCE_WEIGHT = { high: 20, medium: 10, low: 0 }; 5 5 6 + /** 7 + * Creates a normalized evidence item for rendering and export. 8 + */ 6 9 function evidence(label, value, unit = '') { 7 10 return { label, value, unit }; 8 11 } 9 12 13 + /** 14 + * Computes a ranking score for a finding. 15 + */ 10 16 function priority({ severity, confidence, scope, recurrence = 0, affectedTime = 0, dataQualityPenalty = 0 }) { 17 + // Priority is a sorting aid, not a statistical probability. 11 18 return clamp(Math.round( 12 19 (SEVERITY_WEIGHT[severity] || 0) + 13 20 (CONFIDENCE_WEIGHT[confidence] || 0) + ··· 18 25 ), 0, 100); 19 26 } 20 27 28 + /** 29 + * Adds derived fields shared by every finding. 30 + */ 21 31 function finding(source) { 22 32 return { 23 33 limitations: [], ··· 26 36 }; 27 37 } 28 38 39 + /** 40 + * Converts session confidence into a priority penalty. 41 + */ 29 42 function qualityPenalty(confidence) { 43 + // Low-confidence sessions should still surface signals, but they should rank below cleaner evidence. 30 44 if (confidence.score < 45) { 31 45 return 25; 32 46 } ··· 36 50 return 0; 37 51 } 38 52 53 + /** 54 + * Returns whether the session acted too quickly to support speculative search-friction claims. 55 + */ 39 56 function hasFastAction(globalMetrics) { 40 57 return globalMetrics.behaviorOutcome === 'fast-action' || globalMetrics.timeToFirstAction <= 1500; 41 58 } 42 59 60 + /** 61 + * Returns whether enough time remained after the first action to evaluate confirmation behavior. 62 + */ 43 63 function hasEnoughPostActionObservation(globalMetrics) { 44 64 return Math.max(0, globalMetrics.duration - globalMetrics.timeToFirstAction) >= 2500; 45 65 } 46 66 67 + /** 68 + * Produces ranked findings from computed metrics and data-quality context. 69 + */ 47 70 export function buildFindings({ artifact, screenMetrics, elementMetrics, globalMetrics, warnings, confidence }) { 48 71 const findings = []; 49 72 const dataPenalty = qualityPenalty(confidence); 50 73 const fastAction = hasFastAction(globalMetrics); 51 74 75 + // Fast first action suppresses speculative gaze-only friction later in this function. 52 76 if (fastAction) { 53 77 findings.push(finding({ 54 78 id: 'fast-first-action', ··· 130 154 } 131 155 132 156 elementMetrics.forEach((element) => { 157 + // Element-level findings are intentionally stricter because they imply a specific UI target. 133 158 if (element.repeatedClickCount >= 1) { 134 159 findings.push(finding({ 135 160 id: 'element-repeated-clicks', ··· 375 400 } 376 401 }); 377 402 403 + // Quality warnings are rendered through the same finding UI so limitations stay visible in exports and reports. 378 404 warnings.forEach((warning) => { 379 405 findings.push(finding({ 380 406 id: `quality-${warning.code}`, ··· 410 436 return findings.sort((a, b) => b.priority - a.priority); 411 437 } 412 438 439 + /** 440 + * Builds the high-level summary shown at the top of the analysis report. 441 + */ 413 442 export function buildSummary({ findings, confidence, globalMetrics }) { 414 443 const actionable = findings.filter((item) => item.category !== 'quality'); 415 444 const top = actionable.slice(0, 3); ··· 444 473 confidenceScore: confidence.score 445 474 }; 446 475 } 447 -
+79
js/analysisMetrics.js
··· 1 + /** 2 + * Returns the rounded median of finite numeric values. 3 + */ 1 4 export function median(values) { 2 5 const numeric = values.filter(Number.isFinite); 3 6 if (!numeric.length) { ··· 10 13 : Math.round(sorted[middle]); 11 14 } 12 15 16 + /** 17 + * Returns the arithmetic mean of finite numeric values. 18 + */ 13 19 export function average(values) { 14 20 const numeric = values.filter(Number.isFinite); 15 21 return numeric.length ··· 17 23 : 0; 18 24 } 19 25 26 + /** 27 + * Restricts a value to an inclusive numeric range. 28 + */ 20 29 export function clamp(value, min, max) { 21 30 return Math.min(max, Math.max(min, value)); 22 31 } 23 32 33 + /** 34 + * Measures document-space distance between two point-like objects. 35 + */ 24 36 export function distance(a, b) { 25 37 return Math.hypot((a?.docX || a?.x || 0) - (b?.docX || b?.x || 0), (a?.docY || a?.y || 0) - (b?.docY || b?.y || 0)); 26 38 } 27 39 40 + /** 41 + * Groups a list into a Map keyed by a selector function. 42 + */ 28 43 export function groupBy(items, keyFn) { 29 44 const map = new Map(); 30 45 items.forEach((item) => { ··· 37 52 return map; 38 53 } 39 54 55 + /** 56 + * Derives fixation clusters from timestamped gaze points. 57 + */ 40 58 export function detectFixations(points, fixationThreshold = 50, fixationMinDuration = 150) { 41 59 const fixations = []; 42 60 let current = null; 43 61 62 + // Fixations are approximated as one moving centroid per screen until gaze drifts too far away. 44 63 const finalize = (endTime) => { 45 64 if (!current) { 46 65 return; ··· 98 117 return fixations; 99 118 } 100 119 120 + /** 121 + * Computes total same-screen travel distance across a sequence of points. 122 + */ 101 123 export function computeScanpathLength(points) { 102 124 let total = 0; 103 125 for (let index = 1; index < points.length; index += 1) { ··· 109 131 return Math.round(total); 110 132 } 111 133 134 + /** 135 + * Computes gaze dispersion using entropy across a coarse page grid. 136 + */ 112 137 export function computeEntropy(points, width, height) { 113 138 if (!points.length || width <= 0 || height <= 0) { 114 139 return 0; ··· 116 141 const buckets = new Map(); 117 142 const cols = 4; 118 143 const rows = 4; 144 + // A coarse grid is enough to describe spread without pretending pixel-level precision. 119 145 points.forEach((point) => { 120 146 const col = clamp(Math.floor((point.docX / width) * cols), 0, cols - 1); 121 147 const row = clamp(Math.floor((point.docY / height) * rows), 0, rows - 1); ··· 131 157 return Number(entropy.toFixed(2)); 132 158 } 133 159 160 + /** 161 + * Counts unique grid regions visited by gaze points. 162 + */ 134 163 export function computeUniqueZones(points, width, height, cols = 4, rows = 4) { 135 164 if (!points.length || width <= 0 || height <= 0) { 136 165 return 0; ··· 144 173 return zones.size; 145 174 } 146 175 176 + /** 177 + * Returns the most frequently viewed coarse attention zones. 178 + */ 147 179 export function computeDominantZones(points, width, height) { 148 180 if (!points.length || width <= 0 || height <= 0) { 149 181 return []; ··· 166 198 }); 167 199 } 168 200 201 + /** 202 + * Counts how often fixation clusters revisit approximate page regions. 203 + */ 169 204 export function computeRevisitCount(fixations) { 170 205 let revisits = 0; 171 206 const visited = new Set(); ··· 180 215 return revisits; 181 216 } 182 217 218 + /** 219 + * Measures how spread out clicks are around their average position. 220 + */ 183 221 export function computeClickDispersion(clicks) { 184 222 if (clicks.length < 2) { 185 223 return 0; ··· 191 229 return Math.round(average(clicks.map((click) => Math.hypot((click.docX || 0) - center.x, (click.docY || 0) - center.y)))); 192 230 } 193 231 232 + /** 233 + * Counts close repeated clicks that may indicate failed activation. 234 + */ 194 235 export function computeRepeatedClickBursts(clicks) { 195 236 let bursts = 0; 196 237 for (let index = 1; index < clicks.length; index += 1) { ··· 199 240 if (current.screenKey !== previous.screenKey) { 200 241 continue; 201 242 } 243 + // Tight time and distance bounds catch repeated attempts without labeling normal double-clicks everywhere. 202 244 const withinTime = current.timestamp - previous.timestamp <= 1200; 203 245 const withinDistance = Math.hypot((current.docX || 0) - (previous.docX || 0), (current.docY || 0) - (previous.docY || 0)) <= 36; 204 246 if (withinTime && withinDistance) { ··· 208 250 return bursts; 209 251 } 210 252 253 + /** 254 + * Estimates the delay between looking near a target and clicking it. 255 + */ 211 256 export function computePreClickLatency(clicks, fixations) { 212 257 const byScreen = groupBy(fixations, (fixation) => fixation.screenKey); 213 258 const latencies = clicks.map((click) => { 214 259 const candidates = byScreen.get(click.screenKey) || []; 260 + // Walk backwards so the closest prior look wins instead of an older fixation in the same area. 215 261 for (let index = candidates.length - 1; index >= 0; index -= 1) { 216 262 const fixation = candidates[index]; 217 263 if (fixation.endTime > click.timestamp) { ··· 226 272 return latencies.length ? Math.round(average(latencies)) : 0; 227 273 } 228 274 275 + /** 276 + * Calculates what percentage of clicks were preceded by nearby gaze. 277 + */ 229 278 export function computeLookThenActRate(clicks, fixations) { 230 279 if (!clicks.length) { 231 280 return 0; ··· 242 291 return Math.round((looked.length / clicks.length) * 100); 243 292 } 244 293 294 + /** 295 + * Calculates what percentage of clicks were followed by nearby gaze confirmation. 296 + */ 245 297 export function computePostClickConfirmation(clicks, fixations) { 246 298 if (!clicks.length) { 247 299 return 0; ··· 258 310 return Math.round((confirmations.length / clicks.length) * 100); 259 311 } 260 312 313 + /** 314 + * Estimates how long gaze takes to settle after scroll events. 315 + */ 261 316 export function computeScrollToFixationLatency(scrolls, fixations) { 262 317 const byScreen = groupBy(fixations, (fixation) => fixation.screenKey); 263 318 const latencies = scrolls.map((scroll) => { ··· 268 323 return latencies.length ? Math.round(average(latencies)) : 0; 269 324 } 270 325 326 + /** 327 + * Summarizes cursor motion into hesitation and path-efficiency metrics. 328 + */ 271 329 export function computeMouseTraceMetrics(mouseTrace, clicks) { 272 330 if (!mouseTrace.length) { 273 331 return { ··· 296 354 if (segmentDistance < 12 && dt > 1200) { 297 355 idleHesitationWindows += 1; 298 356 } 357 + // Movement near a click is intentional navigation; movement away from actions is treated as possible searching. 299 358 const nearAction = clicks.some((click) => Math.abs(click.timestamp - current.timestamp) <= 1200); 300 359 if (!nearAction) { 301 360 deadMovement += segmentDistance; ··· 316 375 if (leading.length < 2) { 317 376 return null; 318 377 } 378 + // Ratio above 1 means the pointer path was less direct than a straight line to the click. 319 379 const path = computeScanpathLength(leading); 320 380 const direct = Math.hypot(leading[0].docX - (click.docX || 0), leading[0].docY - (click.docY || 0)); 321 381 return direct > 0 ? path / direct : 1; ··· 330 390 }; 331 391 } 332 392 393 + /** 394 + * Measures average distance between gaze and cursor samples near the same timestamp. 395 + */ 333 396 export function computeGazeMouseCoupling(points, mouseTrace) { 334 397 if (!points.length || !mouseTrace.length) { 335 398 return 0; ··· 349 412 return comparisons ? Math.round(total / comparisons) : 0; 350 413 } 351 414 415 + /** 416 + * Computes the deepest scroll position reached as a percentage of scrollable height. 417 + */ 352 418 export function computeScrollDepth(events, screens) { 353 419 const maxDocumentHeight = Math.max(...screens.map((screen) => screen.document.height || screen.viewport.height || 0), 0); 354 420 const maxViewportHeight = Math.max(...screens.map((screen) => screen.viewport.height || 0), 0); ··· 357 423 return clamp(Math.round((maxScroll / scrollable) * 100), 0, 100); 358 424 } 359 425 426 + /** 427 + * Computes scroll depth reached before the first click. 428 + */ 360 429 export function computeScrollDepthBeforeFirstAction(events, screens) { 361 430 const firstClick = events.find((event) => event.type === 'click'); 362 431 const before = firstClick ? events.filter((event) => event.timestamp <= firstClick.timestamp) : events; 363 432 return computeScrollDepth(before, screens); 364 433 } 365 434 435 + /** 436 + * Removes duplicate scroll events created by listening at multiple DOM levels. 437 + */ 366 438 export function dedupeEvents(events) { 367 439 const seen = new Set(); 368 440 return events.filter((event) => { 369 441 if (event.type !== 'scroll') { 370 442 return true; 371 443 } 444 + // Scroll can be observed on both window and document; identical snapshots should count once. 372 445 const key = [ 373 446 event.type, 374 447 event.timestamp, ··· 384 457 }); 385 458 } 386 459 460 + /** 461 + * Tests whether a document-space point lies inside a rectangle. 462 + */ 387 463 export function pointInRect(point, rect) { 388 464 return point.docX >= rect.x && 389 465 point.docX <= rect.x + rect.width && ··· 391 467 point.docY <= rect.y + rect.height; 392 468 } 393 469 470 + /** 471 + * Returns the shortest document-space distance from a point to a rectangle. 472 + */ 394 473 export function distanceToRect(point, rect) { 395 474 const dx = Math.max(rect.x - point.docX, 0, point.docX - (rect.x + rect.width)); 396 475 const dy = Math.max(rect.y - point.docY, 0, point.docY - (rect.y + rect.height));
+42
js/analysisRenderer.js
··· 1 + /** 2 + * Escapes arbitrary values for safe HTML insertion. 3 + */ 1 4 function escapeHtml(value) { 2 5 return String(value ?? '') 3 6 .replaceAll('&', '&amp;') ··· 7 10 .replaceAll("'", '&#39;'); 8 11 } 9 12 13 + /** 14 + * Formats a timestamp for the analysis banner. 15 + */ 10 16 function formatDateTime(value) { 11 17 if (!value) { 12 18 return 'Unknown date'; ··· 15 21 return Number.isNaN(date.getTime()) ? 'Unknown date' : date.toLocaleString(); 16 22 } 17 23 24 + /** 25 + * Formats milliseconds into a short duration label. 26 + */ 18 27 function formatDuration(ms) { 19 28 const seconds = Math.round((ms || 0) / 1000); 20 29 const minutes = Math.floor(seconds / 60); ··· 22 31 return minutes > 0 ? `${minutes}m ${remaining}s` : `${remaining}s`; 23 32 } 24 33 34 + /** 35 + * Builds a styled badge for analysis metadata. 36 + */ 25 37 function buildBadge(text, tone = 'neutral') { 26 38 return `<span class="analysis-badge analysis-badge-${escapeHtml(tone)}">${escapeHtml(text)}</span>`; 27 39 } 28 40 41 + /** 42 + * Formats evidence values for finding lists. 43 + */ 29 44 function formatEvidence(item) { 30 45 if (typeof item === 'string') { 31 46 return escapeHtml(item); ··· 33 48 return `${escapeHtml(item.label)}: ${escapeHtml(item.value)}${escapeHtml(item.unit || '')}`; 34 49 } 35 50 51 + /** 52 + * Renders a list of findings as analysis cards. 53 + */ 36 54 function renderFindingList(findings) { 37 55 if (!findings.length) { 38 56 return '<p class="analysis-empty">No findings were generated for this section.</p>'; 39 57 } 40 58 41 59 return findings.map((finding) => { 60 + // All finding text is escaped here because analysis can include imported session labels. 42 61 const evidence = (finding.evidence || []).map((item) => `<li>${formatEvidence(item)}</li>`).join(''); 43 62 const target = finding.elementFingerprint 44 63 ? `<p class="analysis-target">Element: ${escapeHtml(finding.elementFingerprint)}</p>` ··· 66 85 }).join(''); 67 86 } 68 87 88 + /** 89 + * Renders compact metric tiles for summary panels. 90 + */ 69 91 function renderMetricRail(metrics) { 70 92 return ` 71 93 <div class="analysis-metric-rail"> ··· 79 101 `; 80 102 } 81 103 104 + /** 105 + * Renders data-quality warnings through the finding-card UI. 106 + */ 82 107 function renderWarningList(warnings) { 83 108 if (!warnings.length) { 84 109 return '<p class="analysis-empty">No data quality warnings were generated.</p>'; ··· 95 120 } 96 121 97 122 export class AnalysisRenderer { 123 + /** 124 + * Stores target DOM regions for analysis output. 125 + */ 98 126 constructor({ bannerElement, sections }) { 99 127 this.bannerElement = bannerElement; 100 128 this.sections = sections; 101 129 } 102 130 131 + /** 132 + * Renders a single-session analysis report. 133 + */ 103 134 render({ artifact, analysis }) { 104 135 const fidelityBadges = [ 105 136 buildBadge(`schema v${artifact.schemaVersion}`), ··· 167 198 }); 168 199 } 169 200 201 + /** 202 + * Renders a multi-session imported cohort report. 203 + */ 170 204 renderCohort({ cohortAnalysis, importErrors = [] }) { 171 205 const warnings = [...cohortAnalysis.warnings]; 206 + // Import errors become quality warnings so partial cohort imports remain transparent. 172 207 importErrors.forEach((error) => { 173 208 warnings.push({ 174 209 code: `import-failed-${error.fileName}`, ··· 262 297 this.sections.quality.innerHTML = renderWarningList(warnings); 263 298 } 264 299 300 + /** 301 + * Appends same-app live-session comparison details to the summary. 302 + */ 265 303 renderSessionComparison({ cohortAnalysis }) { 304 + // Live same-app comparisons append to the single-session summary instead of replacing it. 266 305 const repeated = cohortAnalysis.repeatedFindings.slice(0, 3).map((item) => ` 267 306 <li>${escapeHtml(item.title)} appeared in ${escapeHtml(item.sessionCount)} sessions (${escapeHtml(item.share)}%).</li> 268 307 `).join(''); ··· 296 335 `); 297 336 } 298 337 338 + /** 339 + * Clears all analysis output regions. 340 + */ 299 341 reset() { 300 342 this.bannerElement.innerHTML = ''; 301 343 Object.values(this.sections).forEach((element) => {
+27
js/calibration.js
··· 18 18 */ 19 19 20 20 export class CalibrationController { 21 + /** 22 + * Creates the calibration controller and stores UI dependencies. 23 + */ 21 24 constructor({ gazeTracker, elements, getViewportRect, getSafeAreaInsets }) { 22 25 this.gazeTracker = gazeTracker; 23 26 this.elements = elements; ··· 36 39 this.onComplete = null; 37 40 } 38 41 42 + /** 43 + * Resets calibration progress and shows the first target. 44 + */ 39 45 start() { 40 46 this.currentIndex = 0; 41 47 this.currentClicks = 0; ··· 52 58 this.emitState(); 53 59 } 54 60 61 + /** 62 + * Handles a calibration target click and scores the point after enough clicks. 63 + */ 55 64 async handleTargetClick() { 56 65 const point = this.sequence[this.currentIndex]; 57 66 if (!point) { ··· 72 81 await new Promise((resolve) => window.setTimeout(resolve, 220)); 73 82 74 83 const target = this.getTargetPixelPosition(point); 84 + // Score only samples collected since this point became active; earlier calibration noise is ignored. 75 85 const samples = this.gazeTracker.getCalibrationSamples( 76 86 this.currentPointStart, 77 87 Date.now() + this.sampleWindowMs 78 88 ); 89 + // Off-iframe samples are not useful because target positions are measured in the UXET viewport. 79 90 const inIframeSamples = samples.filter((sample) => sample.inIframe); 80 91 const error = inIframeSamples.length 81 92 ? Math.round( ··· 118 129 this.emitState(); 119 130 } 120 131 132 + /** 133 + * Computes the final calibration result after all points are scored. 134 + */ 121 135 finalize() { 122 136 const scoredPoints = this.pointResults.filter((point) => point.passed && Number.isFinite(point.error)); 123 137 const averageError = scoredPoints.length ··· 137 151 return result; 138 152 } 139 153 154 + /** 155 + * Converts a normalized calibration point into viewport pixels. 156 + */ 140 157 getTargetPixelPosition(point) { 141 158 const viewport = this.getViewportRect(); 142 159 const insets = this.getSafeAreaInsets(); 160 + // Insets keep edge targets away from the HUD and browser chrome-sensitive corners. 143 161 const width = Math.max(0, viewport.width - insets.left - insets.right); 144 162 const height = Math.max(0, viewport.height - insets.top - insets.bottom); 145 163 ··· 149 167 }; 150 168 } 151 169 170 + /** 171 + * Updates calibration target position and HUD text. 172 + */ 152 173 updateUi() { 153 174 const point = this.sequence[this.currentIndex]; 154 175 const currentNumber = Math.min(this.currentIndex + 1, this.sequence.length); ··· 165 186 } 166 187 } 167 188 189 + /** 190 + * Converts a pixel error into a user-facing quality label. 191 + */ 168 192 getQualityLabel(error) { 169 193 if (!Number.isFinite(error)) { 170 194 return 'Needs retry'; ··· 178 202 return 'Needs retry'; 179 203 } 180 204 205 + /** 206 + * Notifies subscribers about calibration state changes. 207 + */ 181 208 emitState(result = null) { 182 209 if (this.onStateChange) { 183 210 this.onStateChange({
+25
js/cohortAnalyzer.js
··· 1 1 import { analyzeSessionArtifact } from './sessionAnalyzer.js'; 2 2 import { median } from './analysisMetrics.js'; 3 3 4 + /** 5 + * Returns unique truthy values while preserving first-seen order. 6 + */ 4 7 function unique(values) { 5 8 return Array.from(new Set(values.filter(Boolean))); 6 9 } 7 10 11 + /** 12 + * Normalizes text for comparing task descriptions. 13 + */ 8 14 function normalizeText(value) { 9 15 return String(value || '').trim().toLowerCase().replace(/\s+/g, ' '); 10 16 } 11 17 18 + /** 19 + * Builds the grouping key used to identify repeated findings. 20 + */ 12 21 function findingGroupKey(finding, artifact) { 13 22 const screen = finding.screenKey 14 23 ? artifact.screens.find((item) => item.key === finding.screenKey) 15 24 : null; 25 + // Group by finding type plus target context so unrelated screens do not look like repeated evidence. 16 26 return [ 17 27 finding.id, 18 28 finding.elementFingerprint || '', ··· 20 30 ].join('|'); 21 31 } 22 32 33 + /** 34 + * Appends a value to a Map of arrays. 35 + */ 23 36 function pushMap(map, key, value) { 24 37 if (!map.has(key)) { 25 38 map.set(key, []); ··· 27 40 map.get(key).push(value); 28 41 } 29 42 43 + /** 44 + * Computes completion rate across artifacts with known status. 45 + */ 30 46 function completionRate(artifacts) { 31 47 const known = artifacts.filter((artifact) => artifact.session.status); 32 48 if (!known.length) { ··· 36 52 return Math.round((complete / known.length) * 100); 37 53 } 38 54 55 + /** 56 + * Computes a percentage of analyses matching a predicate. 57 + */ 39 58 function rate(analyses, predicate) { 40 59 if (!analyses.length) { 41 60 return 0; ··· 43 62 return Math.round((analyses.filter(predicate).length / analyses.length) * 100); 44 63 } 45 64 65 + /** 66 + * Compares multiple session artifacts for repeated patterns and outliers. 67 + */ 46 68 export function analyzeSessionCohort(artifacts, options = {}) { 47 69 const analyses = options.analyses || artifacts.map((artifact) => analyzeSessionArtifact(artifact)); 48 70 const appNames = unique(artifacts.map((artifact) => artifact.session.appName)); 49 71 const tasks = unique(artifacts.map((artifact) => artifact.session.task)); 50 72 const mixedAppOrTask = appNames.length > 1 || unique(tasks.map(normalizeText)).length > 1; 73 + // Small cohorts still need at least directional repetition without requiring a large sample size. 51 74 const threshold = Math.min(2, Math.max(1, Math.ceil(artifacts.length * 0.4))); 52 75 const findingGroups = new Map(); 53 76 const elementGroups = new Map(); ··· 109 132 totalRepeatedClicks: items.reduce((sum, item) => sum + item.element.repeatedClickCount, 0) 110 133 }; 111 134 }) 135 + // Only surface repeated element patterns that include some friction-like signal. 112 136 .filter((pattern) => pattern.totalRepeatedClicks > 0 || pattern.medianLookBeforeClickRate < 60 || pattern.medianPostClickConfirmationRate < 60) 113 137 .sort((a, b) => b.totalRepeatedClicks - a.totalRepeatedClicks || a.medianLookBeforeClickRate - b.medianLookBeforeClickRate); 114 138 ··· 127 151 attentionFrictionScore: analysis.globalMetrics.attentionFrictionScore, 128 152 interactionFrictionScore: analysis.globalMetrics.interactionFrictionScore, 129 153 confidenceScore: analysis.confidence.score, 154 + // Pick one plain-language reason so the report stays scannable. 130 155 reason: analysis.globalMetrics.timeToFirstAction >= 5000 131 156 ? 'First action was delayed after pre-action exploration.' 132 157 : analysis.globalMetrics.duration > medianDuration * 1.8
+25
js/debriefRenderer.js
··· 1 + /** 2 + * Loads an image element from a data URL or URL. 3 + */ 1 4 function loadImage(src) { 2 5 return new Promise((resolve, reject) => { 3 6 const image = new Image(); ··· 7 10 }); 8 11 } 9 12 13 + /** 14 + * Maps normalized heat intensity to an RGB color. 15 + */ 10 16 function heatmapColor(t) { 17 + // Blue-to-red makes low-intensity areas visible without overpowering the screenshot. 11 18 if (t < 0.25) { 12 19 const f = t / 0.25; 13 20 return [0, Math.round(f * 180), Math.round(200 + f * 55)]; ··· 24 31 return [255, Math.round(255 - f * 255), 0]; 25 32 } 26 33 34 + /** 35 + * Renders one screen screenshot with a gaze heatmap overlay. 36 + */ 27 37 async function renderHeatmapCanvas(screen) { 28 38 const hasScreenshot = screen.screenshot.status === 'ready' && screen.screenshot.dataUrl; 29 39 const width = screen.screenshot.width || screen.document.width || 1200; ··· 42 52 ctx.drawImage(image, 0, 0, width, height); 43 53 } 44 54 55 + // Build intensity on a separate canvas so colorization can normalize against the actual max alpha. 45 56 const heatCanvas = document.createElement('canvas'); 46 57 heatCanvas.width = width; 47 58 heatCanvas.height = height; ··· 68 79 69 80 for (let index = 0; index < data.length; index += 4) { 70 81 const intensity = data[index + 3] / maxAlpha; 82 + // Drop the faint edge of each radial gradient to keep the overlay readable. 71 83 if (intensity < 0.02) { 72 84 data[index + 3] = 0; 73 85 continue; ··· 85 97 return canvas; 86 98 } 87 99 100 + /** 101 + * Returns gaze points that can be plotted on the screen canvas. 102 + */ 88 103 function getUsablePoints(screen) { 89 104 const width = screen.screenshot.width || screen.document.width || 1200; 90 105 const height = screen.screenshot.height || screen.document.height || 800; ··· 93 108 .filter((point) => point.docX >= 0 && point.docX <= width && point.docY >= 0 && point.docY <= height); 94 109 } 95 110 111 + /** 112 + * Creates a fallback block when a screen cannot render as a heatmap. 113 + */ 96 114 function buildFallback(text) { 97 115 const fallback = document.createElement('div'); 98 116 fallback.className = 'screen-card-fallback'; ··· 101 119 } 102 120 103 121 export class DebriefRenderer { 122 + /** 123 + * Stores gallery DOM elements used by the debrief view. 124 + */ 104 125 constructor({ galleryElement, labelElement }) { 105 126 this.galleryElement = galleryElement; 106 127 this.labelElement = labelElement; 107 128 } 108 129 130 + /** 131 + * Renders all screen cards and heatmaps for a session. 132 + */ 109 133 async render(screens) { 110 134 this.galleryElement.innerHTML = ''; 111 135 ··· 114 138 return; 115 139 } 116 140 141 + // A screen is useful if it has either a screenshot or enough gaze data to explain what is missing. 117 142 const renderableScreens = screens.filter((screen) => { 118 143 const hasScreenshot = screen.screenshot.status === 'ready' && screen.screenshot.dataUrl; 119 144 const usablePoints = getUsablePoints(screen);
+85
js/gazeTracker.js
··· 17 17 * @property {number} documentHeight 18 18 */ 19 19 20 + /** 21 + * Creates a fresh stats object for a gaze tracking run. 22 + */ 20 23 function createEmptyStats() { 21 24 return { 22 25 gazePoints: 0, ··· 28 31 }; 29 32 } 30 33 34 + /** 35 + * Creates the per-screen record used to group gaze, events, and screenshots. 36 + */ 31 37 function createScreenRecord(metrics) { 32 38 return { 33 39 key: metrics.key, ··· 57 63 } 58 64 59 65 export class GazeTracker { 66 + /** 67 + * Initializes in-memory gaze tracking state. 68 + */ 60 69 constructor() { 61 70 this.isInitialized = false; 62 71 this.inputMode = 'webgazer'; ··· 76 85 this.logInterval = 50; 77 86 } 78 87 88 + /** 89 + * Sets whether gaze comes from WebGazer or synthetic mouse movement. 90 + */ 79 91 setInputMode(mode) { 80 92 this.inputMode = mode; 81 93 } 82 94 95 + /** 96 + * Returns the active gaze input mode. 97 + */ 83 98 getInputMode() { 84 99 return this.inputMode; 85 100 } 86 101 102 + /** 103 + * Starts WebGazer and connects incoming gaze samples to UXET. 104 + */ 87 105 async initialize() { 88 106 if (this.isInitialized) { 89 107 return true; ··· 95 113 96 114 try { 97 115 window.saveDataAcrossSessions = false; 116 + // WebGazer loads MediaPipe assets lazily, so keep the path explicit for local static serving. 98 117 webgazer.params.faceMeshSolutionPath = 'https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh'; 99 118 webgazer 100 119 .setRegression('ridge') ··· 117 136 } 118 137 } 119 138 139 + /** 140 + * Sets whether gaze samples are idle, calibration-only, or recorded. 141 + */ 120 142 setMode(mode) { 121 143 this.mode = mode; 122 144 if (mode === 'idle' && typeof webgazer !== 'undefined') { ··· 126 148 } 127 149 } 128 150 151 + /** 152 + * Updates iframe metrics used for coordinate conversion. 153 + */ 129 154 updateMetrics(metrics) { 130 155 this.currentMetrics = metrics ? { ...metrics } : null; 131 156 if (metrics) { ··· 134 159 } 135 160 } 136 161 162 + /** 163 + * Creates or refreshes the screen record for a metrics snapshot. 164 + */ 137 165 ensureScreenRecord(metrics) { 138 166 if (!metrics) { 139 167 return null; ··· 153 181 return record; 154 182 } 155 183 184 + /** 185 + * Stores a captured screenshot on its matching screen record. 186 + */ 156 187 setScreenScreenshot(screenKey, screenshot) { 157 188 const record = this.screenRecords.get(screenKey); 158 189 if (!record) { ··· 168 199 }; 169 200 } 170 201 202 + /** 203 + * Marks screenshot capture as failed for a screen. 204 + */ 171 205 markScreenshotFailed(screenKey) { 172 206 const record = this.screenRecords.get(screenKey); 173 207 if (!record) { ··· 178 212 record.screenshot.dataUrl = null; 179 213 } 180 214 215 + /** 216 + * Stores cloned element snapshots for later element-level analysis. 217 + */ 181 218 setElementSnapshots(screenKey, elementSnapshots = []) { 182 219 const record = this.screenRecords.get(screenKey); 183 220 if (!record) { ··· 188 225 : []; 189 226 } 190 227 228 + /** 229 + * Adds an interaction event to the corresponding screen record. 230 + */ 191 231 recordInteractionEvent(event) { 192 232 const record = this.screenRecords.get(event.screenKey); 193 233 if (record) { ··· 195 235 } 196 236 } 197 237 238 + /** 239 + * Converts a WebGazer sample into UXET's coordinate system. 240 + */ 198 241 handleGaze(data) { 199 242 const now = Date.now(); 200 243 const sample = { ··· 204 247 inIframe: false 205 248 }; 206 249 250 + // Raw samples are kept during calibration even before they become recorded gaze points. 207 251 this.rawSamples.push(sample); 208 252 if (this.rawSamples.length > 400) { 209 253 this.rawSamples.shift(); ··· 216 260 this.stats.lastGazeX = sample.viewportX; 217 261 this.stats.lastGazeY = sample.viewportY; 218 262 263 + // WebGazer reports parent viewport coordinates; analysis needs iframe document coordinates. 219 264 const metrics = this.currentMetrics; 220 265 const iframeX = sample.viewportX - metrics.iframeLeft; 221 266 const iframeY = sample.viewportY - metrics.iframeTop; ··· 242 287 243 288 this.rawSamples[this.rawSamples.length - 1] = fullSample; 244 289 290 + // Throttle accepted points to keep exports and heatmaps usable on long sessions. 245 291 if (this.mode === 'recording' && now - this.lastAcceptedAt >= this.logInterval) { 246 292 this.lastAcceptedAt = now; 247 293 this.acceptGazePoint(fullSample); 248 294 } 249 295 } 250 296 297 + /** 298 + * Accepts mouse-derived gaze samples in debug mode. 299 + */ 251 300 ingestSyntheticGaze(sample) { 252 301 if (!this.currentMetrics || this.mode !== 'recording') { 253 302 return; ··· 266 315 }); 267 316 } 268 317 318 + /** 319 + * Records an accepted gaze point and updates fixation state. 320 + */ 269 321 acceptGazePoint(point) { 270 322 this.stats.gazePoints += 1; 271 323 this.gazePoints.push(point); ··· 290 342 } 291 343 } 292 344 345 + /** 346 + * Updates the active fixation candidate with a new gaze point. 347 + */ 293 348 detectFixation(point) { 294 349 const now = point.timestamp; 295 350 if (!this.currentFixation) { ··· 308 363 const distance = Math.hypot(dx, dy); 309 364 const sameScreen = point.screenKey === this.currentFixation.screenKey; 310 365 366 + // Use a running centroid so fixation centers are less sensitive to single noisy samples. 311 367 if (sameScreen && distance <= this.fixationThreshold) { 312 368 this.currentFixation.pointCount += 1; 313 369 this.currentFixation.x = ··· 329 385 }; 330 386 } 331 387 388 + /** 389 + * Finalizes the current fixation if it meets the duration threshold. 390 + */ 332 391 finalizeFixation(endTime = Date.now()) { 333 392 if (!this.currentFixation) { 334 393 return; ··· 358 417 this.currentFixation = null; 359 418 } 360 419 420 + /** 421 + * Returns recent raw samples for calibration scoring. 422 + */ 361 423 getCalibrationSamples(fromTimestamp) { 362 424 return this.rawSamples.filter((sample) => sample.timestamp >= fromTimestamp); 363 425 } 364 426 427 + /** 428 + * Returns aggregate gaze tracking stats. 429 + */ 365 430 getStats() { 366 431 return { ...this.stats }; 367 432 } 368 433 434 + /** 435 + * Returns accepted gaze points for export and analysis. 436 + */ 369 437 getGazeData() { 370 438 return [...this.gazePoints]; 371 439 } 372 440 441 + /** 442 + * Returns finalized fixations for export and analysis. 443 + */ 373 444 getFixations() { 374 445 return this.fixations.map((fixation) => ({ ...fixation })); 375 446 } 376 447 448 + /** 449 + * Returns cloned screen records so callers cannot mutate tracker state. 450 + */ 377 451 getScreenRecords() { 378 452 return Array.from(this.screenRecords.values()).map((screen) => ({ 379 453 ...screen, ··· 389 463 })); 390 464 } 391 465 466 + /** 467 + * Serializes gaze data into the export shape expected by session artifacts. 468 + */ 392 469 exportData() { 393 470 const screens = this.getScreenRecords(); 394 471 const screenLookup = {}; ··· 419 496 }; 420 497 } 421 498 499 + /** 500 + * Clears all in-memory gaze data without tearing down WebGazer. 501 + */ 422 502 reset() { 423 503 this.inputMode = 'webgazer'; 424 504 this.mode = 'idle'; ··· 433 513 this.lastAcceptedAt = 0; 434 514 } 435 515 516 + /** 517 + * Stops WebGazer, releases camera resources, and finalizes any active fixation. 518 + */ 436 519 async end() { 437 520 this.finalizeFixation(); 438 521 this.setMode('idle'); 439 522 if (typeof webgazer !== 'undefined') { 440 523 try { 441 524 webgazer.pause(); 525 + // WebGazer does not consistently release camera tracks across browsers. 442 526 const videoElement = webgazer.getVideoElement ? webgazer.getVideoElement() : null; 443 527 const stream = videoElement?.srcObject || webgazer.params?.videoStream || null; 444 528 if (stream?.getTracks) { ··· 449 533 console.warn('[GazeTracker] Failed to end WebGazer cleanly:', error); 450 534 } 451 535 } 536 + // Clean up hidden WebGazer video nodes so repeated tests do not accumulate camera elements. 452 537 document.querySelectorAll('#webgazerVideoContainer, video').forEach((element) => { 453 538 if (element.id === 'webgazerVideoContainer' || element.dataset?.webgazerVideoFeed) { 454 539 element.remove();
+53
js/iframeBridge.js
··· 15 15 * @property {number} timestamp 16 16 */ 17 17 18 + /** 19 + * Delays a function until a burst of calls settles. 20 + */ 18 21 function debounce(fn, delay) { 19 22 let timer = null; 20 23 return (...args) => { ··· 23 26 }; 24 27 } 25 28 29 + /** 30 + * Caps how long UXET waits for iframe resources before continuing. 31 + */ 26 32 function withTimeout(promise, ms) { 27 33 return Promise.race([ 28 34 promise, ··· 30 36 ]); 31 37 } 32 38 39 + /** 40 + * Waits briefly for iframe fonts to finish loading. 41 + */ 33 42 async function waitForDocumentFonts(doc) { 34 43 if (!doc?.fonts?.ready) { 35 44 return; ··· 42 51 } 43 52 } 44 53 54 + /** 55 + * Waits briefly for iframe images to become screenshot-ready. 56 + */ 45 57 async function waitForImages(doc) { 46 58 const images = Array.from(doc.images || []); 47 59 const restoreFns = []; 48 60 49 61 images.forEach((image) => { 62 + // Full-page screenshots need lazy images loaded before html2canvas clones the document. 50 63 if (image.loading === 'lazy') { 51 64 const previousLoading = image.loading; 52 65 image.loading = 'eager'; ··· 75 88 } 76 89 77 90 export class IframeBridge { 91 + /** 92 + * Initializes iframe references, observers, and callback hooks. 93 + */ 78 94 constructor() { 79 95 this.iframe = null; 80 96 this.iframeWindow = null; ··· 96 112 }, 700); 97 113 } 98 114 115 + /** 116 + * Attaches UXET to a same-origin iframe document. 117 + */ 99 118 attach(iframe) { 100 119 this.detach(); 101 120 this.iframe = iframe; ··· 120 139 return true; 121 140 } 122 141 142 + /** 143 + * Patches iframe history methods so SPA navigation updates screen metrics. 144 + */ 123 145 patchHistory() { 124 146 const history = this.iframeWindow.history; 125 147 const methods = ['pushState', 'replaceState']; 126 148 methods.forEach((methodName) => { 127 149 const original = history[methodName].bind(history); 128 150 history[methodName] = (...args) => { 151 + // SPA route changes do not trigger iframe load, so patch history to refresh screen keys. 129 152 const result = original(...args); 130 153 this.refreshMetrics(true); 131 154 this._stableNotifier(); ··· 137 160 }); 138 161 } 139 162 163 + /** 164 + * Registers iframe navigation and scroll listeners. 165 + */ 140 166 bindNavigationEvents() { 141 167 const win = this.iframeWindow; 142 168 const doc = this.iframeDocument; ··· 156 182 this.addListener(doc, 'scroll', onMetricsChange, { passive: true, capture: true }); 157 183 } 158 184 185 + /** 186 + * Observes DOM and size changes that can affect screenshots or metrics. 187 + */ 159 188 bindMetricsObservers() { 160 189 const docEl = this.iframeDocument.documentElement; 161 190 const body = this.iframeDocument.body; ··· 185 214 } 186 215 } 187 216 217 + /** 218 + * Reads the iframe's current URL, dimensions, scroll, and document metrics. 219 + */ 188 220 refreshMetrics(screenChanged = false) { 189 221 if (!this.isAttached) { 190 222 return null; ··· 196 228 const docEl = doc.documentElement; 197 229 const body = doc.body; 198 230 const rect = this.iframe.getBoundingClientRect(); 231 + // The screen key intentionally follows URL path/search/hash instead of DOM title. 199 232 const key = `${win.location.pathname}${win.location.search}${win.location.hash}`; 200 233 const metrics = { 201 234 key, ··· 237 270 } 238 271 } 239 272 273 + /** 274 + * Returns the latest metrics snapshot, refreshing if necessary. 275 + */ 240 276 getMetricsSnapshot() { 241 277 if (!this.currentMetrics) { 242 278 return this.refreshMetrics(false); ··· 245 281 return { ...this.currentMetrics }; 246 282 } 247 283 284 + /** 285 + * Captures a full-document screenshot of the iframe. 286 + */ 248 287 async captureScreenshot() { 249 288 if (!this.isAttached) { 250 289 throw new Error('Iframe bridge is not attached.'); ··· 320 359 } 321 360 } 322 361 362 + /** 363 + * Captures candidate interactive elements for element-level analysis. 364 + */ 323 365 captureElementSnapshots(limit = 80) { 324 366 if (!this.isAttached) { 325 367 return []; 326 368 } 327 369 328 370 const doc = this.iframeDocument; 371 + // Capture semantic controls plus app-specific targets without requiring test apps to add UXET hooks. 329 372 const selector = 'a, button, input, select, textarea, label, [role], [data-uxet], [data-id], [onclick], [tabindex]'; 330 373 const candidateSet = new Set(Array.from(doc.querySelectorAll(selector))); 331 374 Array.from(doc.body?.querySelectorAll('*') || []).forEach((element) => { ··· 344 387 const clickable = element.matches(selector) || style.cursor === 'pointer'; 345 388 const ancestor = element.closest('[data-id], [role], a, button, [onclick], [tabindex]'); 346 389 return { 390 + // Fingerprints are stable enough for a session, but not treated as permanent DOM IDs. 347 391 fingerprint: [ 348 392 element.tagName.toLowerCase(), 349 393 element.id || '', ··· 379 423 }); 380 424 } 381 425 426 + /** 427 + * Removes iframe listeners, observers, and patched history methods. 428 + */ 382 429 detach() { 383 430 this.detachFns.forEach((fn) => fn()); 384 431 this.detachFns = []; ··· 403 450 this.isAttached = false; 404 451 } 405 452 453 + /** 454 + * Registers a listener and remembers how to remove it later. 455 + */ 406 456 addListener(target, eventName, handler, options = false) { 407 457 target.addEventListener(eventName, handler, options); 408 458 this.detachFns.push(() => { ··· 410 460 }); 411 461 } 412 462 463 + /** 464 + * Routes iframe access failures to the app-level error handler. 465 + */ 413 466 handleError(error) { 414 467 this.isAttached = false; 415 468 if (this.onError) {
+7
js/importer.js
··· 1 1 import { isLikelyUxetSession, normalizeImportedSession } from './sessionArtifact.js'; 2 2 3 3 export class SessionImporter { 4 + /** 5 + * Reads, validates, and normalizes one UXET JSON file. 6 + */ 4 7 async importFile(file) { 5 8 if (!file) { 6 9 throw new Error('No file was provided.'); ··· 21 24 return normalizeImportedSession(parsed); 22 25 } 23 26 27 + /** 28 + * Imports multiple files and separates valid sessions from errors. 29 + */ 24 30 async importFiles(files) { 25 31 const results = []; 26 32 const errors = []; 27 33 34 + // Partial success is useful during cohort imports: valid sessions can still be analyzed. 28 35 for (const file of Array.from(files || [])) { 29 36 try { 30 37 results.push({
+123
js/main.js
··· 13 13 import { discoverTestableApps } from './testableApps.js'; 14 14 15 15 class UXETApp { 16 + /** 17 + * Creates the application controller and wires module instances to DOM elements. 18 + */ 16 19 constructor() { 17 20 this.themeStorageKey = 'uxet-theme'; 18 21 this.theme = this.getStoredTheme(); ··· 160 163 this.init(); 161 164 } 162 165 166 + /** 167 + * Applies initial UI state and starts test-app discovery. 168 + */ 163 169 init() { 164 170 this.applyTheme(this.theme); 165 171 this.bindEvents(); ··· 169 175 this.loadTestableApps(); 170 176 } 171 177 178 + /** 179 + * Registers all user-interface and window event handlers. 180 + */ 172 181 bindEvents() { 173 182 this.elements.appSelect.addEventListener('change', (event) => this.selectApp(event.target)); 174 183 this.elements.loadAppBtn.addEventListener('click', () => this.loadSelectedApp()); ··· 220 229 }); 221 230 } 222 231 232 + /** 233 + * Connects service callbacks so modules can update each other. 234 + */ 223 235 bindCallbacks() { 224 236 this.session.onStateChange = (state, details) => this.updateUiForState(state, details); 225 237 this.session.onTimerUpdate = (formatted) => { ··· 233 245 if (!this.debugState.mouseGazeMode || this.session.state !== 'recording') { 234 246 return; 235 247 } 248 + // Mouse-as-gaze uses the same document coordinate shape as WebGazer samples. 236 249 this.gazeTracker.ingestSyntheticGaze({ 237 250 timestamp: payload.timestamp, 238 251 viewportX: Math.round(payload.metrics.iframeLeft + payload.clientX), ··· 276 289 }; 277 290 } 278 291 292 + /** 293 + * Discovers test apps and populates the app selector. 294 + */ 279 295 async loadTestableApps() { 280 296 this.elements.loadAppBtn.disabled = true; 281 297 this.elements.appSelect.disabled = true; 282 298 this.elements.appSelect.innerHTML = '<option value="">Loading apps...</option>'; 283 299 284 300 try { 301 + // App metadata is runtime-discovered so index.html does not need hard-coded test targets. 285 302 const { apps, errors } = await discoverTestableApps(); 286 303 this.availableApps = apps; 287 304 this.elements.appSelect.innerHTML = [ ··· 307 324 } 308 325 } 309 326 327 + /** 328 + * Escapes text for insertion into generated HTML. 329 + */ 310 330 escapeHtml(value) { 311 331 return String(value).replace(/[&<>"']/g, (char) => ({ 312 332 '&': '&amp;', ··· 317 337 })[char]); 318 338 } 319 339 340 + /** 341 + * Escapes text for insertion into an HTML attribute. 342 + */ 320 343 escapeAttribute(value) { 321 344 return this.escapeHtml(value); 322 345 } 323 346 347 + /** 348 + * Resolves the selected dropdown value to discovered app metadata. 349 + */ 324 350 getSelectedAppFromElement(selectElement) { 325 351 const value = selectElement?.value || ''; 326 352 if (!value) { ··· 330 356 return this.availableApps.find((app) => app.value === value) || null; 331 357 } 332 358 359 + /** 360 + * Mirrors selected app task text into setup and start-overlay UI. 361 + */ 333 362 syncSelectedAppUi() { 334 363 this.elements.currentTaskText.textContent = this.selectedApp?.task || 'None'; 335 364 this.elements.startOverlayTask.textContent = this.selectedApp?.task || 'Task will appear here.'; 336 365 } 337 366 367 + /** 368 + * Updates the active app selection from the app dropdown. 369 + */ 338 370 selectApp(selectElement) { 339 371 this.selectedApp = this.getSelectedAppFromElement(selectElement); 340 372 this.syncSelectedAppUi(); 341 373 } 342 374 375 + /** 376 + * Enables or disables mouse-derived gaze mode outside active recording. 377 + */ 343 378 toggleMouseGazeMode(enabled) { 344 379 if (this.session.state === 'recording') { 345 380 this.syncDebugUi(); ··· 351 386 this.syncDebugUi(); 352 387 353 388 if (enabled) { 389 + // Mouse mode is a deliberate calibration bypass, not a passed eye-tracking calibration. 354 390 this.calibrationSkippedByDebug = true; 355 391 this.calibrationPassed = false; 356 392 this.lastCalibrationResult = null; ··· 370 406 } 371 407 } 372 408 409 + /** 410 + * Synchronizes debug controls with current session and debug state. 411 + */ 373 412 syncDebugUi() { 374 413 const recording = this.session.state === 'recording'; 375 414 const canSkipCalibration = this.session.state === 'calibrating'; ··· 394 433 this.elements.sessionDebugExitBtn.disabled = !recording; 395 434 } 396 435 436 + /** 437 + * Loads the selected app into the iframe and starts preparation. 438 + */ 397 439 async loadSelectedApp() { 398 440 if (!this.selectedApp) { 399 441 window.alert('Select an app to test.'); ··· 415 457 this.session.setState('loading_app'); 416 458 } 417 459 460 + /** 461 + * Attaches instrumentation once the iframe app has loaded. 462 + */ 418 463 async onIframeLoad() { 419 464 if (!this.selectedApp || !this.elements.iframe.src || this.elements.iframe.src.includes('about:blank')) { 420 465 return; ··· 425 470 return; 426 471 } 427 472 473 + // Wait for layout to settle before converting viewport gaze into iframe document coordinates. 428 474 await this.forceMetricsRefresh(); 429 475 const metrics = this.bridge.getMetricsSnapshot(); 430 476 this.gazeTracker.updateMetrics(metrics); ··· 454 500 this.calibration.start(); 455 501 } 456 502 503 + /** 504 + * Bypasses calibration from debug controls. 505 + */ 457 506 debugSkipCalibration() { 458 507 if (this.session.state !== 'calibrating') { 459 508 return; ··· 467 516 this.session.setState('ready_to_start'); 468 517 } 469 518 519 + /** 520 + * Restarts calibration after a failed attempt. 521 + */ 470 522 retryCalibration() { 471 523 if (this.session.state !== 'calibration_failed') { 472 524 return; ··· 481 533 this.calibration.start(); 482 534 } 483 535 536 + /** 537 + * Starts recording after calibration or an explicit debug bypass. 538 + */ 484 539 beginTesting() { 485 540 if (!this.selectedApp) { 486 541 return; ··· 506 561 }); 507 562 } 508 563 564 + /** 565 + * Ends an active recording from debug controls or the keyboard shortcut. 566 + */ 509 567 debugExitTest() { 510 568 if (this.session.state !== 'recording') { 511 569 return; ··· 513 571 this.finishTest({ strategy: 'debug-manual' }); 514 572 } 515 573 574 + /** 575 + * Stops recording, finalizes capture, and renders the completed debrief. 576 + */ 516 577 async finishTest(details) { 517 578 if (this.session.state !== 'recording') { 518 579 return; ··· 540 601 this.session.setState('complete'); 541 602 } 542 603 604 + /** 605 + * Captures screenshots and element snapshots for a screen key. 606 + */ 543 607 async captureScreen(screenKey) { 544 608 if (!this.bridge.isAttached || !screenKey) { 545 609 return null; ··· 548 612 return this.captureJobs.get(screenKey); 549 613 } 550 614 615 + // Screenshot capture is async and expensive; share the same promise for repeated stable events. 551 616 const job = (async () => { 552 617 try { 553 618 this.gazeTracker.setElementSnapshots(screenKey, this.bridge.captureElementSnapshots()); ··· 567 632 return job; 568 633 } 569 634 635 + /** 636 + * Builds a normalized artifact from the current live session state. 637 + */ 570 638 buildLiveArtifact(details = {}) { 571 639 return createSessionArtifactFromLive({ 572 640 session: this.session.getMetadata(), ··· 592 660 }); 593 661 } 594 662 663 + /** 664 + * Analyzes a live artifact and renders its debrief. 665 + */ 595 666 async renderDebrief(details) { 596 667 const artifact = this.buildLiveArtifact(details); 597 668 const analysis = analyzeSessionArtifact(artifact); ··· 608 679 await this.renderArtifactDebrief(artifact, analysis, details); 609 680 } 610 681 682 + /** 683 + * Renders stats, findings, comparisons, and heatmaps for one artifact. 684 + */ 611 685 async renderArtifactDebrief(artifact, analysis, details = {}) { 612 686 const stats = artifact.interactions.stats; 613 687 ··· 629 703 630 704 this.analysisRenderer.render({ artifact, analysis }); 631 705 const sameAppHistory = this.getSameAppHistory(); 706 + // Repeated live runs are treated as a temporary same-app cohort until reset/reload. 632 707 if (artifact.source === 'live' && sameAppHistory.length >= 2) { 633 708 const cohortAnalysis = analyzeSessionCohort( 634 709 sameAppHistory.map((entry) => entry.artifact), ··· 643 718 this.elements.debriefTestAgainBtn.disabled = !this.lastTestApp || artifact.source !== 'live'; 644 719 } 645 720 721 + /** 722 + * Downloads JSON data through a temporary object URL. 723 + */ 646 724 downloadData(data, filename = `uxet-session-${Date.now()}.json`) { 647 725 const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); 648 726 const url = URL.createObjectURL(blob); ··· 655 733 URL.revokeObjectURL(url); 656 734 } 657 735 736 + /** 737 + * Exports the latest session, cohort, or fallback runtime data. 738 + */ 658 739 exportData() { 659 740 if (this.latestArtifact) { 660 741 this.downloadData({ ··· 739 820 }); 740 821 } 741 822 823 + /** 824 + * Imports one session for debrief or multiple sessions for cohort analysis. 825 + */ 742 826 async handleImport(event) { 743 827 const files = Array.from(event.target?.files || []); 744 828 if (!files.length) { ··· 756 840 if (results.length > 1) { 757 841 const artifacts = results.map((result) => result.artifact); 758 842 const analyses = artifacts.map((artifact) => analyzeSessionArtifact(artifact)); 843 + // Multi-file import is cohort mode; there is no single heatmap gallery to render. 759 844 const cohortAnalysis = analyzeSessionCohort(artifacts, { analyses }); 760 845 this.latestArtifact = null; 761 846 this.latestAnalysis = null; ··· 806 891 } 807 892 } 808 893 894 + /** 895 + * Reads the persisted theme preference. 896 + */ 809 897 getStoredTheme() { 810 898 const storedTheme = window.localStorage.getItem(this.themeStorageKey); 811 899 return storedTheme === 'light' ? 'light' : 'dark'; 812 900 } 813 901 902 + /** 903 + * Applies a theme to the document and theme buttons. 904 + */ 814 905 applyTheme(theme) { 815 906 document.documentElement.dataset.theme = theme; 816 907 this.theme = theme; ··· 820 911 this.elements.themeLightBtn.classList.toggle('active', !darkActive); 821 912 } 822 913 914 + /** 915 + * Persists and applies a selected theme. 916 + */ 823 917 setTheme(theme) { 824 918 if (!['dark', 'light'].includes(theme)) { 825 919 return; ··· 829 923 this.applyTheme(theme); 830 924 } 831 925 926 + /** 927 + * Adds the active theme as a query parameter to app URLs. 928 + */ 832 929 withThemeQuery(path) { 833 930 const url = new URL(path, window.location.href); 834 931 url.searchParams.set('theme', this.theme); 835 932 return `${url.pathname}${url.search}${url.hash}`; 836 933 } 837 934 935 + /** 936 + * Clears instrumentation and debrief state while preserving app selection context. 937 + */ 838 938 async resetRuntimeOnly() { 839 939 this.winConditions.stop(); 840 940 this.tracker.detach(); ··· 869 969 } 870 970 } 871 971 972 + /** 973 + * Fully resets UXET to the initial setup state. 974 + */ 872 975 async resetSession() { 873 976 await this.resetRuntimeOnly(); 874 977 this.session.reset(); ··· 886 989 this.syncDebugUi(); 887 990 } 888 991 992 + /** 993 + * Returns live history entries matching the last tested app and task. 994 + */ 889 995 getSameAppHistory() { 890 996 const app = this.lastTestApp; 891 997 if (!app) { ··· 894 1000 return this.liveSessionHistory.filter((entry) => entry.app.value === app.value && entry.app.task === app.task); 895 1001 } 896 1002 1003 + /** 1004 + * Starts another run of the last completed live app. 1005 + */ 897 1006 async testAgain() { 898 1007 if (!this.lastTestApp) { 899 1008 return; ··· 904 1013 await this.loadSelectedApp(); 905 1014 } 906 1015 1016 + /** 1017 + * Moves the session into an error state and updates visible status. 1018 + */ 907 1019 failSession(message) { 908 1020 this.session.setState('error', { errorMessage: message }); 909 1021 this.elements.sessionMessage.textContent = message; ··· 911 1023 this.syncDebugUi(); 912 1024 } 913 1025 1026 + /** 1027 + * Refreshes iframe metrics after layout has had time to settle. 1028 + */ 914 1029 async forceMetricsRefresh() { 1030 + // Two animation frames lets iframe layout and scroll metrics settle after load or resize. 915 1031 await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); 916 1032 const metrics = this.bridge.refreshMetrics(false); 917 1033 if (metrics) { ··· 920 1036 return metrics; 921 1037 } 922 1038 1039 + /** 1040 + * Refreshes iframe metrics when instrumentation is currently attached. 1041 + */ 923 1042 refreshMetricsIfActive() { 924 1043 if (!this.bridge.isAttached) { 925 1044 return; ··· 930 1049 } 931 1050 } 932 1051 1052 + /** 1053 + * Applies the UI visibility and status rules for a session state. 1054 + */ 933 1055 updateUiForState(state, details = {}) { 1056 + // The session stage hides the tested app until calibration is complete and recording starts. 934 1057 const sessionStates = new Set(['calibrating', 'calibration_failed', 'ready_to_start', 'recording', 'finishing']); 935 1058 const sessionActive = sessionStates.has(state); 936 1059
+34
js/session.js
··· 15 15 ]); 16 16 17 17 export class Session { 18 + /** 19 + * Creates an idle session with empty metadata and timer state. 20 + */ 18 21 constructor() { 19 22 this.state = 'idle'; 20 23 this.startTime = null; ··· 27 30 this.onTimerUpdate = null; 28 31 } 29 32 33 + /** 34 + * Moves the session to a validated lifecycle state. 35 + */ 30 36 setState(nextState, details = {}) { 31 37 if (!VALID_STATES.has(nextState)) { 32 38 throw new Error(`Invalid session state: ${nextState}`); ··· 42 48 this.notifyStateChange(details); 43 49 } 44 50 51 + /** 52 + * Starts the recording timer and enters the recording state. 53 + */ 45 54 startRecording() { 46 55 if (this.state === 'recording') { 47 56 return; ··· 59 68 }, 100); 60 69 } 61 70 71 + /** 72 + * Stops the recording timer and moves to the requested final state. 73 + */ 62 74 stopRecording(nextState = 'complete') { 63 75 if (this.startTime) { 64 76 this.elapsed = Date.now() - this.startTime; ··· 76 88 } 77 89 } 78 90 91 + /** 92 + * Clears session lifecycle, timer, and task metadata. 93 + */ 79 94 reset() { 80 95 if (this.timerInterval) { 81 96 clearInterval(this.timerInterval); ··· 96 111 this.notifyStateChange(); 97 112 } 98 113 114 + /** 115 + * Stores the app name for exports and debriefs. 116 + */ 99 117 setAppName(name) { 100 118 this.appName = name; 101 119 } 102 120 121 + /** 122 + * Stores the active task text for exports and debriefs. 123 + */ 103 124 setTask(task) { 104 125 this.task = task; 105 126 } 106 127 128 + /** 129 + * Formats elapsed milliseconds as HH:MM:SS. 130 + */ 107 131 formatTime(ms) { 108 132 const totalSeconds = Math.floor(ms / 1000); 109 133 const hours = Math.floor(totalSeconds / 3600); ··· 115 139 .join(':'); 116 140 } 117 141 142 + /** 143 + * Emits the current state to the UI controller. 144 + */ 118 145 notifyStateChange(details = {}) { 119 146 if (this.onStateChange) { 120 147 this.onStateChange(this.state, details); 121 148 } 122 149 } 123 150 151 + /** 152 + * Returns serializable session metadata. 153 + */ 124 154 getMetadata() { 125 155 return { 126 156 appName: this.appName, ··· 132 162 }; 133 163 } 134 164 165 + /** 166 + * Downloads a legacy schema-v2 session export. 167 + */ 135 168 exportSession(payload) { 169 + // Kept as a fallback for older call sites; the main export path writes schema v3. 136 170 const data = { 137 171 schemaVersion: '2', 138 172 session: this.getMetadata(),
+49
js/sessionAnalyzer.js
··· 26 26 import { buildElementMetrics } from './analysisElements.js'; 27 27 import { buildFindings, buildSummary } from './analysisFindings.js'; 28 28 29 + /** 30 + * Creates user-facing warnings for missing or lower-fidelity evidence. 31 + */ 29 32 function buildWarnings(artifact) { 30 33 const warnings = []; 31 34 if (artifact.source === 'import' && !['2', '3'].includes(String(artifact.schemaVersion))) { ··· 94 97 return warnings; 95 98 } 96 99 100 + /** 101 + * Scores how trustworthy the analysis is based on warning severity and gaze density. 102 + */ 97 103 function buildConfidence(artifact, warnings) { 98 104 let score = 100; 99 105 const reasons = []; 106 + // Confidence is intentionally conservative: each data-quality issue reduces trust in all downstream claims. 100 107 warnings.forEach((warning) => { 101 108 if (warning.severity === 'high') { 102 109 score -= 20; ··· 123 130 }; 124 131 } 125 132 133 + /** 134 + * Counts revisits to previously seen screen keys. 135 + */ 126 136 function computeBacktracks(events) { 127 137 const screenSequence = []; 128 138 events.forEach((event) => { ··· 134 144 } 135 145 }); 136 146 147 + // Revisiting any prior screen is treated as backtracking, even if the route was not strictly browser history. 137 148 let backtracks = 0; 138 149 const seen = new Set(); 139 150 screenSequence.forEach((key) => { ··· 145 156 return backtracks; 146 157 } 147 158 159 + /** 160 + * Detects clusters of rapid repeated clicks near the same point. 161 + */ 148 162 function computeRageClickCandidates(clicks) { 149 163 const candidates = []; 150 164 for (let index = 2; index < clicks.length; index += 1) { 165 + // Three close clicks inside 1.8s is a conservative signal of repeated failed activation. 151 166 const group = [clicks[index - 2], clicks[index - 1], clicks[index]]; 152 167 const sameScreen = group.every((click) => click.screenKey === group[0].screenKey); 153 168 const withinTime = group[2].timestamp - group[0].timestamp <= 1800; ··· 163 178 return candidates; 164 179 } 165 180 181 + /** 182 + * Finds the first click or key event that represents user action. 183 + */ 166 184 function firstMeaningfulAction(events) { 167 185 return events.find((event) => event.type === 'click' || event.type === 'key') || null; 168 186 } 169 187 188 + /** 189 + * Computes a rectangle's non-negative area. 190 + */ 170 191 function rectArea(rect) { 171 192 return Math.max(0, rect?.width || 0) * Math.max(0, rect?.height || 0); 172 193 } 173 194 195 + /** 196 + * Returns whether an element snapshot is visible enough to count as an AOI. 197 + */ 174 198 function isVisibleAoi(element) { 175 199 return element.visible !== false && rectArea(element.rect) > 0; 176 200 } 177 201 202 + /** 203 + * Finds the earliest timestamp available across gaze, events, and fixations. 204 + */ 178 205 function computeAnalysisStartTime({ points, events, fixations }) { 206 + // Imported legacy sessions can be missing one or more streams, so start from the earliest available evidence. 179 207 const candidates = [ 180 208 points[0]?.timestamp, 181 209 events[0]?.timestamp, ··· 184 212 return candidates.length ? Math.min(...candidates) : 0; 185 213 } 186 214 215 + /** 216 + * Counts distinct visible elements inspected before the first action. 217 + */ 187 218 function countPreActionElements({ screens, fixations }) { 188 219 const elementsByScreen = new Map(screens.map((screen) => [ 189 220 screen.key, ··· 191 222 ])); 192 223 const seen = new Set(); 193 224 225 + // Count distinct AOIs inspected before acting, not repeated looks at the same control. 194 226 fixations.forEach((fixation) => { 195 227 const elements = elementsByScreen.get(fixation.screenKey) || []; 196 228 const point = { docX: fixation.x, docY: fixation.y }; ··· 204 236 return seen.size; 205 237 } 206 238 239 + /** 240 + * Computes attention and exploration metrics before the first meaningful action. 241 + */ 207 242 function computePreActionMetrics({ artifact, points, fixations, events, analysisStartTime }) { 208 243 const firstAction = firstMeaningfulAction(events); 209 244 const actionTime = firstAction?.timestamp ?? null; ··· 229 264 const preActionScrollDepth = computeScrollDepth(preActionEvents, artifact.screens); 230 265 const preActionScanpathLength = computeScanpathLength(preActionPoints); 231 266 const timeToFirstAction = actionTime ? Math.max(0, actionTime - analysisStartTime) : 0; 267 + // This blends time, spread, element coverage, and scrolling into one directional exploration score. 232 268 const preActionExplorationScore = clamp(Math.round( 233 269 (preActionUniqueZones * 7) + 234 270 (preActionUniqueElements * 6) + ··· 252 288 }; 253 289 } 254 290 291 + /** 292 + * Classifies the overall behavioral pattern for a session. 293 + */ 255 294 function classifyBehavior({ globalMetrics, clicks, confidence }) { 295 + // The outcome label gates findings so a quick successful path is not over-explained as friction. 256 296 if (confidence.score < 35 || (!globalMetrics.totalFixations && !globalMetrics.totalClicks)) { 257 297 return 'inconclusive'; 258 298 } ··· 279 319 return 'smooth-action-path'; 280 320 } 281 321 322 + /** 323 + * Builds per-screen attention, interaction, and friction metrics. 324 + */ 282 325 function buildScreenMetrics({ artifact, pointsByScreen, fixationsByScreen, eventsByScreen, analysisStartTime }) { 283 326 return artifact.screens.map((screen) => { 284 327 const screenPoints = pointsByScreen.get(screen.key) || []; ··· 300 343 const timeToFirstFixation = firstFixation 301 344 ? Math.max(0, firstFixation.startTime - analysisStartTime) 302 345 : 0; 346 + // Screen friction is intentionally screen-local; it should not override the session-level outcome by itself. 303 347 const frictionScore = clamp(Math.round( 304 348 (gazeEntropy * 14) + 305 349 (revisitCount * 8) + ··· 339 383 }); 340 384 } 341 385 386 + /** 387 + * Runs the deterministic v3 analysis pipeline for a normalized session artifact. 388 + */ 342 389 export function analyzeSessionArtifact(artifact, options = {}) { 343 390 const points = [...artifact.gaze.points].sort((a, b) => a.timestamp - b.timestamp); 344 391 const events = dedupeEvents([...artifact.interactions.events].sort((a, b) => a.timestamp - b.timestamp)); 345 392 const mouseTrace = [...artifact.interactions.mouseTrace].sort((a, b) => a.timestamp - b.timestamp); 346 393 const clicks = events.filter((event) => event.type === 'click'); 347 394 const scrolls = events.filter((event) => event.type === 'scroll'); 395 + // Live schema v3 exports include fixations; legacy imports can still be analyzed by deriving them. 348 396 const fixations = artifact.gaze.fixations?.length 349 397 ? [...artifact.gaze.fixations].sort((a, b) => a.startTime - b.startTime) 350 398 : detectFixations(points); ··· 360 408 const lookThenActRate = computeLookThenActRate(clicks, fixations); 361 409 const preActionMetrics = computePreActionMetrics({ artifact, points, fixations, events, analysisStartTime }); 362 410 const timeToFirstMeaningfulAction = preActionMetrics.timeToFirstAction; 411 + // These scores are heuristic signals for ranking findings, not validated clinical UX metrics. 363 412 const attentionFrictionScore = clamp(Math.round( 364 413 Math.min(preActionMetrics.timeToFirstAction / 160, 55) + 365 414 (preActionMetrics.preActionUniqueZones * 4) +
+60
js/sessionArtifact.js
··· 1 + /** 2 + * Safely coerces a value to an array. 3 + */ 1 4 function asArray(value) { 2 5 return Array.isArray(value) ? value : []; 3 6 } 4 7 8 + /** 9 + * Safely coerces a value to a plain object. 10 + */ 5 11 function asObject(value) { 6 12 return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; 7 13 } 8 14 15 + /** 16 + * Safely coerces a value to a finite number. 17 + */ 9 18 function asNumber(value, fallback = 0) { 10 19 return Number.isFinite(value) ? value : fallback; 11 20 } 12 21 22 + /** 23 + * Safely coerces a value to a string. 24 + */ 13 25 function asString(value, fallback = '') { 14 26 return typeof value === 'string' ? value : fallback; 15 27 } 16 28 29 + /** 30 + * Returns a timestamp-sorted copy of a collection. 31 + */ 17 32 function sortByTimestamp(items, field = 'timestamp') { 18 33 return [...items].sort((a, b) => asNumber(a?.[field]) - asNumber(b?.[field])); 19 34 } 20 35 36 + /** 37 + * Normalizes interaction counters from current and legacy exports. 38 + */ 21 39 function normalizeInteractionStats(stats) { 22 40 const source = asObject(stats); 23 41 const mouse = asObject(source.mouse); ··· 42 60 }; 43 61 } 44 62 63 + /** 64 + * Normalizes a raw gaze point into schema-v3 shape. 65 + */ 45 66 function normalizeGazePoint(point) { 46 67 const source = asObject(point); 47 68 return { ··· 63 84 }; 64 85 } 65 86 87 + /** 88 + * Normalizes a recorded interaction event. 89 + */ 66 90 function normalizeInteractionEvent(event) { 67 91 const source = asObject(event); 68 92 return { ··· 88 112 }; 89 113 } 90 114 115 + /** 116 + * Normalizes a dense mouse trace sample. 117 + */ 91 118 function normalizeMouseTracePoint(point) { 92 119 const source = asObject(point); 93 120 return { ··· 103 130 }; 104 131 } 105 132 133 + /** 134 + * Normalizes a fixation record. 135 + */ 106 136 function normalizeFixation(fixation) { 107 137 const source = asObject(fixation); 108 138 return { ··· 116 146 }; 117 147 } 118 148 149 + /** 150 + * Normalizes a captured element snapshot. 151 + */ 119 152 function normalizeElementSnapshot(snapshot) { 120 153 const source = asObject(snapshot); 121 154 const rect = asObject(source.rect); ··· 142 175 }; 143 176 } 144 177 178 + /** 179 + * Normalizes a full screen record. 180 + */ 145 181 function normalizeScreenRecord(screen) { 146 182 const source = asObject(screen); 147 183 const viewport = asObject(source.viewport); ··· 160 196 width: asNumber(documentInfo.width, asNumber(source.documentWidth)), 161 197 height: asNumber(documentInfo.height, asNumber(source.documentHeight)) 162 198 }, 199 + // Older exports stored screenshot metadata as flat fields; v3 stores it as an object. 163 200 screenshot: { 164 201 dataUrl: typeof screenshot.dataUrl === 'string' ? screenshot.dataUrl : null, 165 202 width: asNumber(screenshot.width, asNumber(source.screenshotWidth)), ··· 174 211 }; 175 212 } 176 213 214 + /** 215 + * Converts a legacy screen summary into a screen record. 216 + */ 177 217 function normalizeScreenSummary(summary) { 178 218 const source = asObject(summary); 179 219 return normalizeScreenRecord({ ··· 192 232 }); 193 233 } 194 234 235 + /** 236 + * Ensures screens include points and events that reference them. 237 + */ 195 238 function enrichScreensWithPoints(screens, points, events) { 196 239 const map = new Map(screens.map((screen) => [screen.key, screen])); 197 240 ··· 199 242 if (map.has(screenKey)) { 200 243 return map.get(screenKey); 201 244 } 245 + // Legacy exports can have points/events but no screen records; synthesize a minimal screen. 202 246 const inferred = normalizeScreenRecord({ key: screenKey, title: screenKey }); 203 247 map.set(screenKey, inferred); 204 248 return inferred; ··· 215 259 return Array.from(map.values()).sort((a, b) => a.firstSeenAt - b.firstSeenAt); 216 260 } 217 261 262 + /** 263 + * Builds fidelity flags used by warnings and confidence scoring. 264 + */ 218 265 function buildFidelity({ screens, mouseTrace, points, debug }) { 266 + // Fidelity flags let analysis explain missing evidence instead of silently lowering output quality. 219 267 return { 220 268 hasScreenshots: screens.some((screen) => screen.screenshot.status === 'ready' && screen.screenshot.dataUrl), 221 269 hasMouseTrace: mouseTrace.length > 0, ··· 225 273 }; 226 274 } 227 275 276 + /** 277 + * Creates a normalized schema-v3 artifact from a live UXET session. 278 + */ 228 279 export function createSessionArtifactFromLive({ 229 280 session, 230 281 gaze, ··· 238 289 const interactionEvents = sortByTimestamp(asArray(interactions?.events).map(normalizeInteractionEvent)); 239 290 const mouseTrace = sortByTimestamp(asArray(interactions?.mouseTrace).map(normalizeMouseTracePoint)); 240 291 const fixations = sortByTimestamp(asArray(gaze?.fixations).map(normalizeFixation), 'startTime'); 292 + // Normalize live data through the same path as imports so the analyzer sees one schema. 241 293 const screens = enrichScreensWithPoints( 242 294 asArray(screenRecords).map(normalizeScreenRecord), 243 295 gazePoints, ··· 278 330 }; 279 331 } 280 332 333 + /** 334 + * Normalizes imported UXET JSON from current or legacy export shapes. 335 + */ 281 336 export function normalizeImportedSession(raw) { 282 337 const source = asObject(raw); 283 338 const schemaVersion = String(source.schemaVersion || '1'); ··· 286 341 const gaze = asObject(source.gaze); 287 342 const interactions = asObject(source.interactions); 288 343 const analysis = asObject(source.analysis); 344 + // Some schema-v3 files may contain only embedded analysis; mark them so confidence reflects that. 289 345 const analysisContext = { 290 346 ...asObject(source.analysisContext), 291 347 embeddedAnalysisOnly: Boolean(schemaVersion === '3' && source.analysis && (!source.gaze || !source.interactions)) ··· 299 355 const fixations = sortByTimestamp(asArray(source.fixations || gaze.fixations).map(normalizeFixation), 'startTime'); 300 356 301 357 let screens = []; 358 + // Accept all historical screen shapes UXET has exported. 302 359 if (Array.isArray(importedScreenRecords)) { 303 360 screens = importedScreenRecords.map(normalizeScreenRecord); 304 361 } else if (Array.isArray(importedScreens)) { ··· 344 401 }; 345 402 } 346 403 404 + /** 405 + * Checks whether JSON looks like a UXET export before normalization. 406 + */ 347 407 export function isLikelyUxetSession(data) { 348 408 const source = asObject(data); 349 409 return Boolean(source.session && ((source.gaze && source.interactions) || source.analysis));
+19
js/testableApps.js
··· 1 1 const TESTABLE_APPS_ROOT = 'testable-apps/'; 2 2 const APP_METADATA_FILE = 'app.json'; 3 3 4 + /** 5 + * Extracts one app directory name from a directory-listing link. 6 + */ 4 7 function normalizeDirectoryHref(href) { 5 8 if (!href || href.startsWith('?') || href.startsWith('#')) { 6 9 return null; 7 10 } 8 11 12 + // Directory-listing links are usually relative to /testable-apps/, not to the UXET page. 9 13 const rootUrl = new URL(TESTABLE_APPS_ROOT, window.location.href); 10 14 const url = new URL(href, rootUrl.href); 11 15 if (!url.href.startsWith(rootUrl.href) || !url.pathname.endsWith('/')) { ··· 21 25 return directoryName; 22 26 } 23 27 28 + /** 29 + * Parses the local HTTP directory listing into app folder names. 30 + */ 24 31 function parseDirectoryListing(html) { 32 + // This intentionally relies on the simple HTML index produced by python -m http.server. 25 33 const document = new DOMParser().parseFromString(html, 'text/html'); 26 34 return Array.from(document.querySelectorAll('a[href]')) 27 35 .map((link) => normalizeDirectoryHref(link.getAttribute('href'))) ··· 30 38 .sort((a, b) => a.localeCompare(b)); 31 39 } 32 40 41 + /** 42 + * Validates app metadata and converts it into UXET's app option shape. 43 + */ 33 44 function normalizeMetadata(directoryName, metadata) { 34 45 const name = typeof metadata?.name === 'string' ? metadata.name.trim() : ''; 35 46 const task = typeof metadata?.task === 'string' ? metadata.task.trim() : ''; ··· 44 55 }; 45 56 } 46 57 58 + /** 59 + * Fetches and parses a JSON file with a helpful HTTP error. 60 + */ 47 61 async function fetchJson(url) { 48 62 const response = await fetch(url, { cache: 'no-store' }); 49 63 if (!response.ok) { ··· 52 66 return response.json(); 53 67 } 54 68 69 + /** 70 + * Discovers available test apps from the local testable-apps directory. 71 + */ 55 72 export async function discoverTestableApps() { 73 + // Static sites cannot read local folders directly; the local HTTP directory index is the discovery API. 56 74 const response = await fetch(TESTABLE_APPS_ROOT, { cache: 'no-store' }); 57 75 if (!response.ok) { 58 76 throw new Error(`Failed to inspect ${TESTABLE_APPS_ROOT}: ${response.status}`); ··· 63 81 throw new Error(`No app folders were found in ${TESTABLE_APPS_ROOT}.`); 64 82 } 65 83 84 + // One bad app.json should not hide every other valid app from the tester. 66 85 const results = await Promise.allSettled(directories.map(async (directoryName) => { 67 86 const metadata = await fetchJson(`${TESTABLE_APPS_ROOT}${encodeURIComponent(directoryName)}/${APP_METADATA_FILE}`); 68 87 return normalizeMetadata(directoryName, metadata);
+54
js/tracker.js
··· 2 2 * UXET Tracker - interaction event tracking with screen-aware metadata 3 3 */ 4 4 5 + /** 6 + * Creates a fresh interaction stats object. 7 + */ 5 8 function createInitialStats() { 6 9 return { 7 10 mouse: { ··· 24 27 } 25 28 26 29 export class Tracker { 30 + /** 31 + * Initializes interaction tracking state. 32 + */ 27 33 constructor() { 28 34 this.isRecording = false; 29 35 this.events = []; ··· 40 46 this.onMousePosition = null; 41 47 } 42 48 49 + /** 50 + * Attaches event listeners to the current iframe document. 51 + */ 43 52 attach(bridge) { 44 53 this.detach(); 45 54 this.bridge = bridge; ··· 57 66 return true; 58 67 } 59 68 69 + /** 70 + * Registers an event listener and tracks its cleanup callback. 71 + */ 60 72 addListener(target, eventName, handler, options = false) { 61 73 target.addEventListener(eventName, handler, options); 62 74 this.detachFns.push(() => target.removeEventListener(eventName, handler, options)); 63 75 } 64 76 77 + /** 78 + * Removes all listeners from the current iframe. 79 + */ 65 80 detach() { 66 81 this.detachFns.forEach((fn) => fn()); 67 82 this.detachFns = []; 68 83 this.bridge = null; 69 84 } 70 85 86 + /** 87 + * Begins accepting interaction events. 88 + */ 71 89 start() { 72 90 this.isRecording = true; 73 91 this.startTime = Date.now(); 74 92 } 75 93 94 + /** 95 + * Stops accepting interaction events. 96 + */ 76 97 stop() { 77 98 this.isRecording = false; 78 99 } 79 100 101 + /** 102 + * Clears recorded events, traces, and counters. 103 + */ 80 104 reset() { 81 105 this.stop(); 82 106 this.events = []; ··· 88 112 this.lastTraceAt = 0; 89 113 } 90 114 115 + /** 116 + * Records cursor movement, velocity, and dense mouse trace samples. 117 + */ 91 118 handleMouseMove(event) { 92 119 if (!this.isRecording) { 93 120 return; ··· 135 162 }); 136 163 } 137 164 165 + // Dense mouse trace feeds movement metrics; the event log below stays lower-volume. 138 166 if (now - this.lastTraceAt >= this.mouseTraceInterval) { 139 167 const velocity = this.velocities.length ? this.velocities[this.velocities.length - 1] : 0; 140 168 this.mouseTrace.push({ ··· 156 184 } 157 185 } 158 186 187 + /** 188 + * Records click metadata and a short target ancestry path. 189 + */ 159 190 handleClick(event) { 160 191 if (!this.isRecording) { 161 192 return; ··· 176 207 target?.innerText?.trim?.()?.slice(0, 80) || 177 208 target?.value || 178 209 ''; 210 + // Fingerprints bridge click events back to element snapshots when geometry is ambiguous. 179 211 const clickTargetFingerprint = [ 180 212 tagName, 181 213 target?.id || '', ··· 185 217 ].filter(Boolean).join('|'); 186 218 const targetPath = []; 187 219 let cursor = target; 220 + // A short ancestor path helps debug delegated click handlers without exporting the whole DOM. 188 221 while (cursor && targetPath.length < 4 && cursor !== this.bridge.iframeDocument.body) { 189 222 const rect = cursor.getBoundingClientRect?.(); 190 223 const label = cursor.getAttribute?.('aria-label') || ··· 223 256 }); 224 257 } 225 258 259 + /** 260 + * Records scroll activity for the active screen. 261 + */ 226 262 handleScroll() { 227 263 if (!this.isRecording) { 228 264 return; ··· 233 269 this.logEvent('scroll', 'scroll', null, metrics); 234 270 } 235 271 272 + /** 273 + * Records keyboard activity and correction counts. 274 + */ 236 275 handleKeyDown(event) { 237 276 if (!this.isRecording) { 238 277 return; ··· 251 290 this.logEvent('key', `key:${this.stats.keyboard.lastKey}`, event, metrics); 252 291 } 253 292 293 + /** 294 + * Appends a normalized interaction event to the session log. 295 + */ 254 296 logEvent(type, message, event, metrics, extra = {}) { 255 297 const payload = { 256 298 timestamp: Date.now(), ··· 273 315 } 274 316 } 275 317 318 + /** 319 + * Returns cloned interaction counters. 320 + */ 276 321 getStats() { 277 322 return { 278 323 mouse: { ...this.stats.mouse }, ··· 281 326 }; 282 327 } 283 328 329 + /** 330 + * Returns recorded interaction events. 331 + */ 284 332 getEvents() { 285 333 return [...this.events]; 286 334 } 287 335 336 + /** 337 + * Returns dense cursor trace samples. 338 + */ 288 339 getMouseTrace() { 289 340 return [...this.mouseTrace]; 290 341 } 291 342 343 + /** 344 + * Serializes tracker output for session artifacts. 345 + */ 292 346 exportData() { 293 347 return { 294 348 stats: this.getStats(),
+13
js/winConditions.js
··· 1 + /** 2 + * Creates the standardized iframe postMessage completion listener. 3 + */ 1 4 function createPostMessageEvaluator() { 2 5 return { 3 6 handler: null, 4 7 start({ bridge, complete, session }) { 5 8 this.stop(); 6 9 this.handler = (event) => { 10 + // Only the active iframe is allowed to complete the task. 7 11 if (session.state !== 'recording') { 8 12 return; 9 13 } ··· 29 33 } 30 34 31 35 export class WinConditionRegistry { 36 + /** 37 + * Creates a registry with no active completion listener. 38 + */ 32 39 constructor() { 33 40 this.activeEvaluator = null; 34 41 } 35 42 43 + /** 44 + * Starts listening for the active iframe's completion message. 45 + */ 36 46 start(context) { 37 47 this.stop(); 38 48 this.activeEvaluator = createPostMessageEvaluator(); 39 49 this.activeEvaluator.start(context); 40 50 } 41 51 52 + /** 53 + * Stops any active completion listener. 54 + */ 42 55 stop() { 43 56 if (this.activeEvaluator) { 44 57 this.activeEvaluator.stop();