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

Configure Feed

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

Add descriptors to methods

+675
+24
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); ··· 33 45 .sort((a, b) => a.distance - b.distance)[0]?.element || null; 34 46 } 35 47 48 + /** 49 + * Counts close repeated clicks within one element's click stream. 50 + */ 36 51 function repeatedClickCount(clicks) { 37 52 let count = 0; 38 53 for (let index = 1; index < clicks.length; index += 1) { ··· 46 61 return count; 47 62 } 48 63 64 + /** 65 + * Checks whether the user looked near a click target before clicking. 66 + */ 49 67 function hasLookBeforeClick(click, fixations) { 50 68 return fixations.some((fixation) => 51 69 fixation.endTime <= click.timestamp && ··· 54 72 ); 55 73 } 56 74 75 + /** 76 + * Checks whether gaze returned near a clicked target after the action. 77 + */ 57 78 function hasPostClickConfirmation(click, fixations) { 58 79 return fixations.some((fixation) => 59 80 fixation.startTime >= click.timestamp && ··· 62 83 ); 63 84 } 64 85 86 + /** 87 + * Builds per-element interaction and attention metrics from screen snapshots. 88 + */ 65 89 export function buildElementMetrics({ screens, events, fixations }) { 66 90 const eventsByScreen = groupBy(events, (event) => event.screenKey); 67 91 const fixationsByScreen = groupBy(fixations, (fixation) => fixation.screenKey);
+24
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 }) { 11 17 // Priority is a sorting aid, not a statistical probability. 12 18 return clamp(Math.round( ··· 19 25 ), 0, 100); 20 26 } 21 27 28 + /** 29 + * Adds derived fields shared by every finding. 30 + */ 22 31 function finding(source) { 23 32 return { 24 33 limitations: [], ··· 27 36 }; 28 37 } 29 38 39 + /** 40 + * Converts session confidence into a priority penalty. 41 + */ 30 42 function qualityPenalty(confidence) { 31 43 // Low-confidence sessions should still surface signals, but they should rank below cleaner evidence. 32 44 if (confidence.score < 45) { ··· 38 50 return 0; 39 51 } 40 52 53 + /** 54 + * Returns whether the session acted too quickly to support speculative search-friction claims. 55 + */ 41 56 function hasFastAction(globalMetrics) { 42 57 return globalMetrics.behaviorOutcome === 'fast-action' || globalMetrics.timeToFirstAction <= 1500; 43 58 } 44 59 60 + /** 61 + * Returns whether enough time remained after the first action to evaluate confirmation behavior. 62 + */ 45 63 function hasEnoughPostActionObservation(globalMetrics) { 46 64 return Math.max(0, globalMetrics.duration - globalMetrics.timeToFirstAction) >= 2500; 47 65 } 48 66 67 + /** 68 + * Produces ranked findings from computed metrics and data-quality context. 69 + */ 49 70 export function buildFindings({ artifact, screenMetrics, elementMetrics, globalMetrics, warnings, confidence }) { 50 71 const findings = []; 51 72 const dataPenalty = qualityPenalty(confidence); ··· 415 436 return findings.sort((a, b) => b.priority - a.priority); 416 437 } 417 438 439 + /** 440 + * Builds the high-level summary shown at the top of the analysis report. 441 + */ 418 442 export function buildSummary({ findings, confidence, globalMetrics }) { 419 443 const actionable = findings.filter((item) => item.category !== 'quality'); 420 444 const top = actionable.slice(0, 3);
+72
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; ··· 99 117 return fixations; 100 118 } 101 119 120 + /** 121 + * Computes total same-screen travel distance across a sequence of points. 122 + */ 102 123 export function computeScanpathLength(points) { 103 124 let total = 0; 104 125 for (let index = 1; index < points.length; index += 1) { ··· 110 131 return Math.round(total); 111 132 } 112 133 134 + /** 135 + * Computes gaze dispersion using entropy across a coarse page grid. 136 + */ 113 137 export function computeEntropy(points, width, height) { 114 138 if (!points.length || width <= 0 || height <= 0) { 115 139 return 0; ··· 133 157 return Number(entropy.toFixed(2)); 134 158 } 135 159 160 + /** 161 + * Counts unique grid regions visited by gaze points. 162 + */ 136 163 export function computeUniqueZones(points, width, height, cols = 4, rows = 4) { 137 164 if (!points.length || width <= 0 || height <= 0) { 138 165 return 0; ··· 146 173 return zones.size; 147 174 } 148 175 176 + /** 177 + * Returns the most frequently viewed coarse attention zones. 178 + */ 149 179 export function computeDominantZones(points, width, height) { 150 180 if (!points.length || width <= 0 || height <= 0) { 151 181 return []; ··· 168 198 }); 169 199 } 170 200 201 + /** 202 + * Counts how often fixation clusters revisit approximate page regions. 203 + */ 171 204 export function computeRevisitCount(fixations) { 172 205 let revisits = 0; 173 206 const visited = new Set(); ··· 182 215 return revisits; 183 216 } 184 217 218 + /** 219 + * Measures how spread out clicks are around their average position. 220 + */ 185 221 export function computeClickDispersion(clicks) { 186 222 if (clicks.length < 2) { 187 223 return 0; ··· 193 229 return Math.round(average(clicks.map((click) => Math.hypot((click.docX || 0) - center.x, (click.docY || 0) - center.y)))); 194 230 } 195 231 232 + /** 233 + * Counts close repeated clicks that may indicate failed activation. 234 + */ 196 235 export function computeRepeatedClickBursts(clicks) { 197 236 let bursts = 0; 198 237 for (let index = 1; index < clicks.length; index += 1) { ··· 211 250 return bursts; 212 251 } 213 252 253 + /** 254 + * Estimates the delay between looking near a target and clicking it. 255 + */ 214 256 export function computePreClickLatency(clicks, fixations) { 215 257 const byScreen = groupBy(fixations, (fixation) => fixation.screenKey); 216 258 const latencies = clicks.map((click) => { ··· 230 272 return latencies.length ? Math.round(average(latencies)) : 0; 231 273 } 232 274 275 + /** 276 + * Calculates what percentage of clicks were preceded by nearby gaze. 277 + */ 233 278 export function computeLookThenActRate(clicks, fixations) { 234 279 if (!clicks.length) { 235 280 return 0; ··· 246 291 return Math.round((looked.length / clicks.length) * 100); 247 292 } 248 293 294 + /** 295 + * Calculates what percentage of clicks were followed by nearby gaze confirmation. 296 + */ 249 297 export function computePostClickConfirmation(clicks, fixations) { 250 298 if (!clicks.length) { 251 299 return 0; ··· 262 310 return Math.round((confirmations.length / clicks.length) * 100); 263 311 } 264 312 313 + /** 314 + * Estimates how long gaze takes to settle after scroll events. 315 + */ 265 316 export function computeScrollToFixationLatency(scrolls, fixations) { 266 317 const byScreen = groupBy(fixations, (fixation) => fixation.screenKey); 267 318 const latencies = scrolls.map((scroll) => { ··· 272 323 return latencies.length ? Math.round(average(latencies)) : 0; 273 324 } 274 325 326 + /** 327 + * Summarizes cursor motion into hesitation and path-efficiency metrics. 328 + */ 275 329 export function computeMouseTraceMetrics(mouseTrace, clicks) { 276 330 if (!mouseTrace.length) { 277 331 return { ··· 336 390 }; 337 391 } 338 392 393 + /** 394 + * Measures average distance between gaze and cursor samples near the same timestamp. 395 + */ 339 396 export function computeGazeMouseCoupling(points, mouseTrace) { 340 397 if (!points.length || !mouseTrace.length) { 341 398 return 0; ··· 355 412 return comparisons ? Math.round(total / comparisons) : 0; 356 413 } 357 414 415 + /** 416 + * Computes the deepest scroll position reached as a percentage of scrollable height. 417 + */ 358 418 export function computeScrollDepth(events, screens) { 359 419 const maxDocumentHeight = Math.max(...screens.map((screen) => screen.document.height || screen.viewport.height || 0), 0); 360 420 const maxViewportHeight = Math.max(...screens.map((screen) => screen.viewport.height || 0), 0); ··· 363 423 return clamp(Math.round((maxScroll / scrollable) * 100), 0, 100); 364 424 } 365 425 426 + /** 427 + * Computes scroll depth reached before the first click. 428 + */ 366 429 export function computeScrollDepthBeforeFirstAction(events, screens) { 367 430 const firstClick = events.find((event) => event.type === 'click'); 368 431 const before = firstClick ? events.filter((event) => event.timestamp <= firstClick.timestamp) : events; 369 432 return computeScrollDepth(before, screens); 370 433 } 371 434 435 + /** 436 + * Removes duplicate scroll events created by listening at multiple DOM levels. 437 + */ 372 438 export function dedupeEvents(events) { 373 439 const seen = new Set(); 374 440 return events.filter((event) => { ··· 391 457 }); 392 458 } 393 459 460 + /** 461 + * Tests whether a document-space point lies inside a rectangle. 462 + */ 394 463 export function pointInRect(point, rect) { 395 464 return point.docX >= rect.x && 396 465 point.docX <= rect.x + rect.width && ··· 398 467 point.docY <= rect.y + rect.height; 399 468 } 400 469 470 + /** 471 + * Returns the shortest document-space distance from a point to a rectangle. 472 + */ 401 473 export function distanceToRect(point, rect) { 402 474 const dx = Math.max(rect.x - point.docX, 0, point.docX - (rect.x + rect.width)); 403 475 const dy = Math.max(rect.y - point.docY, 0, point.docY - (rect.y + rect.height));
+39
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>'; ··· 67 85 }).join(''); 68 86 } 69 87 88 + /** 89 + * Renders compact metric tiles for summary panels. 90 + */ 70 91 function renderMetricRail(metrics) { 71 92 return ` 72 93 <div class="analysis-metric-rail"> ··· 80 101 `; 81 102 } 82 103 104 + /** 105 + * Renders data-quality warnings through the finding-card UI. 106 + */ 83 107 function renderWarningList(warnings) { 84 108 if (!warnings.length) { 85 109 return '<p class="analysis-empty">No data quality warnings were generated.</p>'; ··· 96 120 } 97 121 98 122 export class AnalysisRenderer { 123 + /** 124 + * Stores target DOM regions for analysis output. 125 + */ 99 126 constructor({ bannerElement, sections }) { 100 127 this.bannerElement = bannerElement; 101 128 this.sections = sections; 102 129 } 103 130 131 + /** 132 + * Renders a single-session analysis report. 133 + */ 104 134 render({ artifact, analysis }) { 105 135 const fidelityBadges = [ 106 136 buildBadge(`schema v${artifact.schemaVersion}`), ··· 168 198 }); 169 199 } 170 200 201 + /** 202 + * Renders a multi-session imported cohort report. 203 + */ 171 204 renderCohort({ cohortAnalysis, importErrors = [] }) { 172 205 const warnings = [...cohortAnalysis.warnings]; 173 206 // Import errors become quality warnings so partial cohort imports remain transparent. ··· 264 297 this.sections.quality.innerHTML = renderWarningList(warnings); 265 298 } 266 299 300 + /** 301 + * Appends same-app live-session comparison details to the summary. 302 + */ 267 303 renderSessionComparison({ cohortAnalysis }) { 268 304 // Live same-app comparisons append to the single-session summary instead of replacing it. 269 305 const repeated = cohortAnalysis.repeatedFindings.slice(0, 3).map((item) => ` ··· 299 335 `); 300 336 } 301 337 338 + /** 339 + * Clears all analysis output regions. 340 + */ 302 341 reset() { 303 342 this.bannerElement.innerHTML = ''; 304 343 Object.values(this.sections).forEach((element) => {
+24
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) { ··· 120 129 this.emitState(); 121 130 } 122 131 132 + /** 133 + * Computes the final calibration result after all points are scored. 134 + */ 123 135 finalize() { 124 136 const scoredPoints = this.pointResults.filter((point) => point.passed && Number.isFinite(point.error)); 125 137 const averageError = scoredPoints.length ··· 139 151 return result; 140 152 } 141 153 154 + /** 155 + * Converts a normalized calibration point into viewport pixels. 156 + */ 142 157 getTargetPixelPosition(point) { 143 158 const viewport = this.getViewportRect(); 144 159 const insets = this.getSafeAreaInsets(); ··· 152 167 }; 153 168 } 154 169 170 + /** 171 + * Updates calibration target position and HUD text. 172 + */ 155 173 updateUi() { 156 174 const point = this.sequence[this.currentIndex]; 157 175 const currentNumber = Math.min(this.currentIndex + 1, this.sequence.length); ··· 168 186 } 169 187 } 170 188 189 + /** 190 + * Converts a pixel error into a user-facing quality label. 191 + */ 171 192 getQualityLabel(error) { 172 193 if (!Number.isFinite(error)) { 173 194 return 'Needs retry'; ··· 181 202 return 'Needs retry'; 182 203 } 183 204 205 + /** 206 + * Notifies subscribers about calibration state changes. 207 + */ 184 208 emitState(result = null) { 185 209 if (this.onStateChange) { 186 210 this.onStateChange({
+21
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) ··· 21 30 ].join('|'); 22 31 } 23 32 33 + /** 34 + * Appends a value to a Map of arrays. 35 + */ 24 36 function pushMap(map, key, value) { 25 37 if (!map.has(key)) { 26 38 map.set(key, []); ··· 28 40 map.get(key).push(value); 29 41 } 30 42 43 + /** 44 + * Computes completion rate across artifacts with known status. 45 + */ 31 46 function completionRate(artifacts) { 32 47 const known = artifacts.filter((artifact) => artifact.session.status); 33 48 if (!known.length) { ··· 37 52 return Math.round((complete / known.length) * 100); 38 53 } 39 54 55 + /** 56 + * Computes a percentage of analyses matching a predicate. 57 + */ 40 58 function rate(analyses, predicate) { 41 59 if (!analyses.length) { 42 60 return 0; ··· 44 62 return Math.round((analyses.filter(predicate).length / analyses.length) * 100); 45 63 } 46 64 65 + /** 66 + * Compares multiple session artifacts for repeated patterns and outliers. 67 + */ 47 68 export function analyzeSessionCohort(artifacts, options = {}) { 48 69 const analyses = options.analyses || artifacts.map((artifact) => analyzeSessionArtifact(artifact)); 49 70 const appNames = unique(artifacts.map((artifact) => artifact.session.appName));
+21
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) { 11 17 // Blue-to-red makes low-intensity areas visible without overpowering the screenshot. 12 18 if (t < 0.25) { ··· 25 31 return [255, Math.round(255 - f * 255), 0]; 26 32 } 27 33 34 + /** 35 + * Renders one screen screenshot with a gaze heatmap overlay. 36 + */ 28 37 async function renderHeatmapCanvas(screen) { 29 38 const hasScreenshot = screen.screenshot.status === 'ready' && screen.screenshot.dataUrl; 30 39 const width = screen.screenshot.width || screen.document.width || 1200; ··· 88 97 return canvas; 89 98 } 90 99 100 + /** 101 + * Returns gaze points that can be plotted on the screen canvas. 102 + */ 91 103 function getUsablePoints(screen) { 92 104 const width = screen.screenshot.width || screen.document.width || 1200; 93 105 const height = screen.screenshot.height || screen.document.height || 800; ··· 96 108 .filter((point) => point.docX >= 0 && point.docX <= width && point.docY >= 0 && point.docY <= height); 97 109 } 98 110 111 + /** 112 + * Creates a fallback block when a screen cannot render as a heatmap. 113 + */ 99 114 function buildFallback(text) { 100 115 const fallback = document.createElement('div'); 101 116 fallback.className = 'screen-card-fallback'; ··· 104 119 } 105 120 106 121 export class DebriefRenderer { 122 + /** 123 + * Stores gallery DOM elements used by the debrief view. 124 + */ 107 125 constructor({ galleryElement, labelElement }) { 108 126 this.galleryElement = galleryElement; 109 127 this.labelElement = labelElement; 110 128 } 111 129 130 + /** 131 + * Renders all screen cards and heatmaps for a session. 132 + */ 112 133 async render(screens) { 113 134 this.galleryElement.innerHTML = ''; 114 135
+78
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; ··· 118 136 } 119 137 } 120 138 139 + /** 140 + * Sets whether gaze samples are idle, calibration-only, or recorded. 141 + */ 121 142 setMode(mode) { 122 143 this.mode = mode; 123 144 if (mode === 'idle' && typeof webgazer !== 'undefined') { ··· 127 148 } 128 149 } 129 150 151 + /** 152 + * Updates iframe metrics used for coordinate conversion. 153 + */ 130 154 updateMetrics(metrics) { 131 155 this.currentMetrics = metrics ? { ...metrics } : null; 132 156 if (metrics) { ··· 135 159 } 136 160 } 137 161 162 + /** 163 + * Creates or refreshes the screen record for a metrics snapshot. 164 + */ 138 165 ensureScreenRecord(metrics) { 139 166 if (!metrics) { 140 167 return null; ··· 154 181 return record; 155 182 } 156 183 184 + /** 185 + * Stores a captured screenshot on its matching screen record. 186 + */ 157 187 setScreenScreenshot(screenKey, screenshot) { 158 188 const record = this.screenRecords.get(screenKey); 159 189 if (!record) { ··· 169 199 }; 170 200 } 171 201 202 + /** 203 + * Marks screenshot capture as failed for a screen. 204 + */ 172 205 markScreenshotFailed(screenKey) { 173 206 const record = this.screenRecords.get(screenKey); 174 207 if (!record) { ··· 179 212 record.screenshot.dataUrl = null; 180 213 } 181 214 215 + /** 216 + * Stores cloned element snapshots for later element-level analysis. 217 + */ 182 218 setElementSnapshots(screenKey, elementSnapshots = []) { 183 219 const record = this.screenRecords.get(screenKey); 184 220 if (!record) { ··· 189 225 : []; 190 226 } 191 227 228 + /** 229 + * Adds an interaction event to the corresponding screen record. 230 + */ 192 231 recordInteractionEvent(event) { 193 232 const record = this.screenRecords.get(event.screenKey); 194 233 if (record) { ··· 196 235 } 197 236 } 198 237 238 + /** 239 + * Converts a WebGazer sample into UXET's coordinate system. 240 + */ 199 241 handleGaze(data) { 200 242 const now = Date.now(); 201 243 const sample = { ··· 252 294 } 253 295 } 254 296 297 + /** 298 + * Accepts mouse-derived gaze samples in debug mode. 299 + */ 255 300 ingestSyntheticGaze(sample) { 256 301 if (!this.currentMetrics || this.mode !== 'recording') { 257 302 return; ··· 270 315 }); 271 316 } 272 317 318 + /** 319 + * Records an accepted gaze point and updates fixation state. 320 + */ 273 321 acceptGazePoint(point) { 274 322 this.stats.gazePoints += 1; 275 323 this.gazePoints.push(point); ··· 294 342 } 295 343 } 296 344 345 + /** 346 + * Updates the active fixation candidate with a new gaze point. 347 + */ 297 348 detectFixation(point) { 298 349 const now = point.timestamp; 299 350 if (!this.currentFixation) { ··· 334 385 }; 335 386 } 336 387 388 + /** 389 + * Finalizes the current fixation if it meets the duration threshold. 390 + */ 337 391 finalizeFixation(endTime = Date.now()) { 338 392 if (!this.currentFixation) { 339 393 return; ··· 363 417 this.currentFixation = null; 364 418 } 365 419 420 + /** 421 + * Returns recent raw samples for calibration scoring. 422 + */ 366 423 getCalibrationSamples(fromTimestamp) { 367 424 return this.rawSamples.filter((sample) => sample.timestamp >= fromTimestamp); 368 425 } 369 426 427 + /** 428 + * Returns aggregate gaze tracking stats. 429 + */ 370 430 getStats() { 371 431 return { ...this.stats }; 372 432 } 373 433 434 + /** 435 + * Returns accepted gaze points for export and analysis. 436 + */ 374 437 getGazeData() { 375 438 return [...this.gazePoints]; 376 439 } 377 440 441 + /** 442 + * Returns finalized fixations for export and analysis. 443 + */ 378 444 getFixations() { 379 445 return this.fixations.map((fixation) => ({ ...fixation })); 380 446 } 381 447 448 + /** 449 + * Returns cloned screen records so callers cannot mutate tracker state. 450 + */ 382 451 getScreenRecords() { 383 452 return Array.from(this.screenRecords.values()).map((screen) => ({ 384 453 ...screen, ··· 394 463 })); 395 464 } 396 465 466 + /** 467 + * Serializes gaze data into the export shape expected by session artifacts. 468 + */ 397 469 exportData() { 398 470 const screens = this.getScreenRecords(); 399 471 const screenLookup = {}; ··· 424 496 }; 425 497 } 426 498 499 + /** 500 + * Clears all in-memory gaze data without tearing down WebGazer. 501 + */ 427 502 reset() { 428 503 this.inputMode = 'webgazer'; 429 504 this.mode = 'idle'; ··· 438 513 this.lastAcceptedAt = 0; 439 514 } 440 515 516 + /** 517 + * Stops WebGazer, releases camera resources, and finalizes any active fixation. 518 + */ 441 519 async end() { 442 520 this.finalizeFixation(); 443 521 this.setMode('idle');
+48
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 = []; ··· 76 88 } 77 89 78 90 export class IframeBridge { 91 + /** 92 + * Initializes iframe references, observers, and callback hooks. 93 + */ 79 94 constructor() { 80 95 this.iframe = null; 81 96 this.iframeWindow = null; ··· 97 112 }, 700); 98 113 } 99 114 115 + /** 116 + * Attaches UXET to a same-origin iframe document. 117 + */ 100 118 attach(iframe) { 101 119 this.detach(); 102 120 this.iframe = iframe; ··· 121 139 return true; 122 140 } 123 141 142 + /** 143 + * Patches iframe history methods so SPA navigation updates screen metrics. 144 + */ 124 145 patchHistory() { 125 146 const history = this.iframeWindow.history; 126 147 const methods = ['pushState', 'replaceState']; ··· 139 160 }); 140 161 } 141 162 163 + /** 164 + * Registers iframe navigation and scroll listeners. 165 + */ 142 166 bindNavigationEvents() { 143 167 const win = this.iframeWindow; 144 168 const doc = this.iframeDocument; ··· 158 182 this.addListener(doc, 'scroll', onMetricsChange, { passive: true, capture: true }); 159 183 } 160 184 185 + /** 186 + * Observes DOM and size changes that can affect screenshots or metrics. 187 + */ 161 188 bindMetricsObservers() { 162 189 const docEl = this.iframeDocument.documentElement; 163 190 const body = this.iframeDocument.body; ··· 187 214 } 188 215 } 189 216 217 + /** 218 + * Reads the iframe's current URL, dimensions, scroll, and document metrics. 219 + */ 190 220 refreshMetrics(screenChanged = false) { 191 221 if (!this.isAttached) { 192 222 return null; ··· 240 270 } 241 271 } 242 272 273 + /** 274 + * Returns the latest metrics snapshot, refreshing if necessary. 275 + */ 243 276 getMetricsSnapshot() { 244 277 if (!this.currentMetrics) { 245 278 return this.refreshMetrics(false); ··· 248 281 return { ...this.currentMetrics }; 249 282 } 250 283 284 + /** 285 + * Captures a full-document screenshot of the iframe. 286 + */ 251 287 async captureScreenshot() { 252 288 if (!this.isAttached) { 253 289 throw new Error('Iframe bridge is not attached.'); ··· 323 359 } 324 360 } 325 361 362 + /** 363 + * Captures candidate interactive elements for element-level analysis. 364 + */ 326 365 captureElementSnapshots(limit = 80) { 327 366 if (!this.isAttached) { 328 367 return []; ··· 384 423 }); 385 424 } 386 425 426 + /** 427 + * Removes iframe listeners, observers, and patched history methods. 428 + */ 387 429 detach() { 388 430 this.detachFns.forEach((fn) => fn()); 389 431 this.detachFns = []; ··· 408 450 this.isAttached = false; 409 451 } 410 452 453 + /** 454 + * Registers a listener and remembers how to remove it later. 455 + */ 411 456 addListener(target, eventName, handler, options = false) { 412 457 target.addEventListener(eventName, handler, options); 413 458 this.detachFns.push(() => { ··· 415 460 }); 416 461 } 417 462 463 + /** 464 + * Routes iframe access failures to the app-level error handler. 465 + */ 418 466 handleError(error) { 419 467 this.isAttached = false; 420 468 if (this.onError) {
+6
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 = [];
+114
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) => { ··· 277 289 }; 278 290 } 279 291 292 + /** 293 + * Discovers test apps and populates the app selector. 294 + */ 280 295 async loadTestableApps() { 281 296 this.elements.loadAppBtn.disabled = true; 282 297 this.elements.appSelect.disabled = true; ··· 309 324 } 310 325 } 311 326 327 + /** 328 + * Escapes text for insertion into generated HTML. 329 + */ 312 330 escapeHtml(value) { 313 331 return String(value).replace(/[&<>"']/g, (char) => ({ 314 332 '&': '&amp;', ··· 319 337 })[char]); 320 338 } 321 339 340 + /** 341 + * Escapes text for insertion into an HTML attribute. 342 + */ 322 343 escapeAttribute(value) { 323 344 return this.escapeHtml(value); 324 345 } 325 346 347 + /** 348 + * Resolves the selected dropdown value to discovered app metadata. 349 + */ 326 350 getSelectedAppFromElement(selectElement) { 327 351 const value = selectElement?.value || ''; 328 352 if (!value) { ··· 332 356 return this.availableApps.find((app) => app.value === value) || null; 333 357 } 334 358 359 + /** 360 + * Mirrors selected app task text into setup and start-overlay UI. 361 + */ 335 362 syncSelectedAppUi() { 336 363 this.elements.currentTaskText.textContent = this.selectedApp?.task || 'None'; 337 364 this.elements.startOverlayTask.textContent = this.selectedApp?.task || 'Task will appear here.'; 338 365 } 339 366 367 + /** 368 + * Updates the active app selection from the app dropdown. 369 + */ 340 370 selectApp(selectElement) { 341 371 this.selectedApp = this.getSelectedAppFromElement(selectElement); 342 372 this.syncSelectedAppUi(); 343 373 } 344 374 375 + /** 376 + * Enables or disables mouse-derived gaze mode outside active recording. 377 + */ 345 378 toggleMouseGazeMode(enabled) { 346 379 if (this.session.state === 'recording') { 347 380 this.syncDebugUi(); ··· 373 406 } 374 407 } 375 408 409 + /** 410 + * Synchronizes debug controls with current session and debug state. 411 + */ 376 412 syncDebugUi() { 377 413 const recording = this.session.state === 'recording'; 378 414 const canSkipCalibration = this.session.state === 'calibrating'; ··· 397 433 this.elements.sessionDebugExitBtn.disabled = !recording; 398 434 } 399 435 436 + /** 437 + * Loads the selected app into the iframe and starts preparation. 438 + */ 400 439 async loadSelectedApp() { 401 440 if (!this.selectedApp) { 402 441 window.alert('Select an app to test.'); ··· 418 457 this.session.setState('loading_app'); 419 458 } 420 459 460 + /** 461 + * Attaches instrumentation once the iframe app has loaded. 462 + */ 421 463 async onIframeLoad() { 422 464 if (!this.selectedApp || !this.elements.iframe.src || this.elements.iframe.src.includes('about:blank')) { 423 465 return; ··· 458 500 this.calibration.start(); 459 501 } 460 502 503 + /** 504 + * Bypasses calibration from debug controls. 505 + */ 461 506 debugSkipCalibration() { 462 507 if (this.session.state !== 'calibrating') { 463 508 return; ··· 471 516 this.session.setState('ready_to_start'); 472 517 } 473 518 519 + /** 520 + * Restarts calibration after a failed attempt. 521 + */ 474 522 retryCalibration() { 475 523 if (this.session.state !== 'calibration_failed') { 476 524 return; ··· 485 533 this.calibration.start(); 486 534 } 487 535 536 + /** 537 + * Starts recording after calibration or an explicit debug bypass. 538 + */ 488 539 beginTesting() { 489 540 if (!this.selectedApp) { 490 541 return; ··· 510 561 }); 511 562 } 512 563 564 + /** 565 + * Ends an active recording from debug controls or the keyboard shortcut. 566 + */ 513 567 debugExitTest() { 514 568 if (this.session.state !== 'recording') { 515 569 return; ··· 517 571 this.finishTest({ strategy: 'debug-manual' }); 518 572 } 519 573 574 + /** 575 + * Stops recording, finalizes capture, and renders the completed debrief. 576 + */ 520 577 async finishTest(details) { 521 578 if (this.session.state !== 'recording') { 522 579 return; ··· 544 601 this.session.setState('complete'); 545 602 } 546 603 604 + /** 605 + * Captures screenshots and element snapshots for a screen key. 606 + */ 547 607 async captureScreen(screenKey) { 548 608 if (!this.bridge.isAttached || !screenKey) { 549 609 return null; ··· 572 632 return job; 573 633 } 574 634 635 + /** 636 + * Builds a normalized artifact from the current live session state. 637 + */ 575 638 buildLiveArtifact(details = {}) { 576 639 return createSessionArtifactFromLive({ 577 640 session: this.session.getMetadata(), ··· 597 660 }); 598 661 } 599 662 663 + /** 664 + * Analyzes a live artifact and renders its debrief. 665 + */ 600 666 async renderDebrief(details) { 601 667 const artifact = this.buildLiveArtifact(details); 602 668 const analysis = analyzeSessionArtifact(artifact); ··· 613 679 await this.renderArtifactDebrief(artifact, analysis, details); 614 680 } 615 681 682 + /** 683 + * Renders stats, findings, comparisons, and heatmaps for one artifact. 684 + */ 616 685 async renderArtifactDebrief(artifact, analysis, details = {}) { 617 686 const stats = artifact.interactions.stats; 618 687 ··· 649 718 this.elements.debriefTestAgainBtn.disabled = !this.lastTestApp || artifact.source !== 'live'; 650 719 } 651 720 721 + /** 722 + * Downloads JSON data through a temporary object URL. 723 + */ 652 724 downloadData(data, filename = `uxet-session-${Date.now()}.json`) { 653 725 const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); 654 726 const url = URL.createObjectURL(blob); ··· 661 733 URL.revokeObjectURL(url); 662 734 } 663 735 736 + /** 737 + * Exports the latest session, cohort, or fallback runtime data. 738 + */ 664 739 exportData() { 665 740 if (this.latestArtifact) { 666 741 this.downloadData({ ··· 745 820 }); 746 821 } 747 822 823 + /** 824 + * Imports one session for debrief or multiple sessions for cohort analysis. 825 + */ 748 826 async handleImport(event) { 749 827 const files = Array.from(event.target?.files || []); 750 828 if (!files.length) { ··· 813 891 } 814 892 } 815 893 894 + /** 895 + * Reads the persisted theme preference. 896 + */ 816 897 getStoredTheme() { 817 898 const storedTheme = window.localStorage.getItem(this.themeStorageKey); 818 899 return storedTheme === 'light' ? 'light' : 'dark'; 819 900 } 820 901 902 + /** 903 + * Applies a theme to the document and theme buttons. 904 + */ 821 905 applyTheme(theme) { 822 906 document.documentElement.dataset.theme = theme; 823 907 this.theme = theme; ··· 827 911 this.elements.themeLightBtn.classList.toggle('active', !darkActive); 828 912 } 829 913 914 + /** 915 + * Persists and applies a selected theme. 916 + */ 830 917 setTheme(theme) { 831 918 if (!['dark', 'light'].includes(theme)) { 832 919 return; ··· 836 923 this.applyTheme(theme); 837 924 } 838 925 926 + /** 927 + * Adds the active theme as a query parameter to app URLs. 928 + */ 839 929 withThemeQuery(path) { 840 930 const url = new URL(path, window.location.href); 841 931 url.searchParams.set('theme', this.theme); 842 932 return `${url.pathname}${url.search}${url.hash}`; 843 933 } 844 934 935 + /** 936 + * Clears instrumentation and debrief state while preserving app selection context. 937 + */ 845 938 async resetRuntimeOnly() { 846 939 this.winConditions.stop(); 847 940 this.tracker.detach(); ··· 876 969 } 877 970 } 878 971 972 + /** 973 + * Fully resets UXET to the initial setup state. 974 + */ 879 975 async resetSession() { 880 976 await this.resetRuntimeOnly(); 881 977 this.session.reset(); ··· 893 989 this.syncDebugUi(); 894 990 } 895 991 992 + /** 993 + * Returns live history entries matching the last tested app and task. 994 + */ 896 995 getSameAppHistory() { 897 996 const app = this.lastTestApp; 898 997 if (!app) { ··· 901 1000 return this.liveSessionHistory.filter((entry) => entry.app.value === app.value && entry.app.task === app.task); 902 1001 } 903 1002 1003 + /** 1004 + * Starts another run of the last completed live app. 1005 + */ 904 1006 async testAgain() { 905 1007 if (!this.lastTestApp) { 906 1008 return; ··· 911 1013 await this.loadSelectedApp(); 912 1014 } 913 1015 1016 + /** 1017 + * Moves the session into an error state and updates visible status. 1018 + */ 914 1019 failSession(message) { 915 1020 this.session.setState('error', { errorMessage: message }); 916 1021 this.elements.sessionMessage.textContent = message; ··· 918 1023 this.syncDebugUi(); 919 1024 } 920 1025 1026 + /** 1027 + * Refreshes iframe metrics after layout has had time to settle. 1028 + */ 921 1029 async forceMetricsRefresh() { 922 1030 // Two animation frames lets iframe layout and scroll metrics settle after load or resize. 923 1031 await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); ··· 928 1036 return metrics; 929 1037 } 930 1038 1039 + /** 1040 + * Refreshes iframe metrics when instrumentation is currently attached. 1041 + */ 931 1042 refreshMetricsIfActive() { 932 1043 if (!this.bridge.isAttached) { 933 1044 return; ··· 938 1049 } 939 1050 } 940 1051 1052 + /** 1053 + * Applies the UI visibility and status rules for a session state. 1054 + */ 941 1055 updateUiForState(state, details = {}) { 942 1056 // The session stage hides the tested app until calibration is complete and recording starts. 943 1057 const sessionStates = new Set(['calibrating', 'calibration_failed', 'ready_to_start', 'recording', 'finishing']);
+33
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) { 136 169 // Kept as a fallback for older call sites; the main export path writes schema v3. 137 170 const data = {
+39
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 = []; ··· 124 130 }; 125 131 } 126 132 133 + /** 134 + * Counts revisits to previously seen screen keys. 135 + */ 127 136 function computeBacktracks(events) { 128 137 const screenSequence = []; 129 138 events.forEach((event) => { ··· 147 156 return backtracks; 148 157 } 149 158 159 + /** 160 + * Detects clusters of rapid repeated clicks near the same point. 161 + */ 150 162 function computeRageClickCandidates(clicks) { 151 163 const candidates = []; 152 164 for (let index = 2; index < clicks.length; index += 1) { ··· 166 178 return candidates; 167 179 } 168 180 181 + /** 182 + * Finds the first click or key event that represents user action. 183 + */ 169 184 function firstMeaningfulAction(events) { 170 185 return events.find((event) => event.type === 'click' || event.type === 'key') || null; 171 186 } 172 187 188 + /** 189 + * Computes a rectangle's non-negative area. 190 + */ 173 191 function rectArea(rect) { 174 192 return Math.max(0, rect?.width || 0) * Math.max(0, rect?.height || 0); 175 193 } 176 194 195 + /** 196 + * Returns whether an element snapshot is visible enough to count as an AOI. 197 + */ 177 198 function isVisibleAoi(element) { 178 199 return element.visible !== false && rectArea(element.rect) > 0; 179 200 } 180 201 202 + /** 203 + * Finds the earliest timestamp available across gaze, events, and fixations. 204 + */ 181 205 function computeAnalysisStartTime({ points, events, fixations }) { 182 206 // Imported legacy sessions can be missing one or more streams, so start from the earliest available evidence. 183 207 const candidates = [ ··· 188 212 return candidates.length ? Math.min(...candidates) : 0; 189 213 } 190 214 215 + /** 216 + * Counts distinct visible elements inspected before the first action. 217 + */ 191 218 function countPreActionElements({ screens, fixations }) { 192 219 const elementsByScreen = new Map(screens.map((screen) => [ 193 220 screen.key, ··· 209 236 return seen.size; 210 237 } 211 238 239 + /** 240 + * Computes attention and exploration metrics before the first meaningful action. 241 + */ 212 242 function computePreActionMetrics({ artifact, points, fixations, events, analysisStartTime }) { 213 243 const firstAction = firstMeaningfulAction(events); 214 244 const actionTime = firstAction?.timestamp ?? null; ··· 258 288 }; 259 289 } 260 290 291 + /** 292 + * Classifies the overall behavioral pattern for a session. 293 + */ 261 294 function classifyBehavior({ globalMetrics, clicks, confidence }) { 262 295 // The outcome label gates findings so a quick successful path is not over-explained as friction. 263 296 if (confidence.score < 35 || (!globalMetrics.totalFixations && !globalMetrics.totalClicks)) { ··· 286 319 return 'smooth-action-path'; 287 320 } 288 321 322 + /** 323 + * Builds per-screen attention, interaction, and friction metrics. 324 + */ 289 325 function buildScreenMetrics({ artifact, pointsByScreen, fixationsByScreen, eventsByScreen, analysisStartTime }) { 290 326 return artifact.screens.map((screen) => { 291 327 const screenPoints = pointsByScreen.get(screen.key) || []; ··· 347 383 }); 348 384 } 349 385 386 + /** 387 + * Runs the deterministic v3 analysis pipeline for a normalized session artifact. 388 + */ 350 389 export function analyzeSessionArtifact(artifact, options = {}) { 351 390 const points = [...artifact.gaze.points].sort((a, b) => a.timestamp - b.timestamp); 352 391 const events = dedupeEvents([...artifact.interactions.events].sort((a, b) => a.timestamp - b.timestamp));
+54
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); ··· 175 211 }; 176 212 } 177 213 214 + /** 215 + * Converts a legacy screen summary into a screen record. 216 + */ 178 217 function normalizeScreenSummary(summary) { 179 218 const source = asObject(summary); 180 219 return normalizeScreenRecord({ ··· 193 232 }); 194 233 } 195 234 235 + /** 236 + * Ensures screens include points and events that reference them. 237 + */ 196 238 function enrichScreensWithPoints(screens, points, events) { 197 239 const map = new Map(screens.map((screen) => [screen.key, screen])); 198 240 ··· 217 259 return Array.from(map.values()).sort((a, b) => a.firstSeenAt - b.firstSeenAt); 218 260 } 219 261 262 + /** 263 + * Builds fidelity flags used by warnings and confidence scoring. 264 + */ 220 265 function buildFidelity({ screens, mouseTrace, points, debug }) { 221 266 // Fidelity flags let analysis explain missing evidence instead of silently lowering output quality. 222 267 return { ··· 228 273 }; 229 274 } 230 275 276 + /** 277 + * Creates a normalized schema-v3 artifact from a live UXET session. 278 + */ 231 279 export function createSessionArtifactFromLive({ 232 280 session, 233 281 gaze, ··· 282 330 }; 283 331 } 284 332 333 + /** 334 + * Normalizes imported UXET JSON from current or legacy export shapes. 335 + */ 285 336 export function normalizeImportedSession(raw) { 286 337 const source = asObject(raw); 287 338 const schemaVersion = String(source.schemaVersion || '1'); ··· 350 401 }; 351 402 } 352 403 404 + /** 405 + * Checks whether JSON looks like a UXET export before normalization. 406 + */ 353 407 export function isLikelyUxetSession(data) { 354 408 const source = asObject(data); 355 409 return Boolean(source.session && ((source.gaze && source.interactions) || source.analysis));
+15
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; ··· 22 25 return directoryName; 23 26 } 24 27 28 + /** 29 + * Parses the local HTTP directory listing into app folder names. 30 + */ 25 31 function parseDirectoryListing(html) { 26 32 // This intentionally relies on the simple HTML index produced by python -m http.server. 27 33 const document = new DOMParser().parseFromString(html, 'text/html'); ··· 32 38 .sort((a, b) => a.localeCompare(b)); 33 39 } 34 40 41 + /** 42 + * Validates app metadata and converts it into UXET's app option shape. 43 + */ 35 44 function normalizeMetadata(directoryName, metadata) { 36 45 const name = typeof metadata?.name === 'string' ? metadata.name.trim() : ''; 37 46 const task = typeof metadata?.task === 'string' ? metadata.task.trim() : ''; ··· 46 55 }; 47 56 } 48 57 58 + /** 59 + * Fetches and parses a JSON file with a helpful HTTP error. 60 + */ 49 61 async function fetchJson(url) { 50 62 const response = await fetch(url, { cache: 'no-store' }); 51 63 if (!response.ok) { ··· 54 66 return response.json(); 55 67 } 56 68 69 + /** 70 + * Discovers available test apps from the local testable-apps directory. 71 + */ 57 72 export async function discoverTestableApps() { 58 73 // Static sites cannot read local folders directly; the local HTTP directory index is the discovery API. 59 74 const response = await fetch(TESTABLE_APPS_ROOT, { cache: 'no-store' });
+51
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; ··· 157 184 } 158 185 } 159 186 187 + /** 188 + * Records click metadata and a short target ancestry path. 189 + */ 160 190 handleClick(event) { 161 191 if (!this.isRecording) { 162 192 return; ··· 226 256 }); 227 257 } 228 258 259 + /** 260 + * Records scroll activity for the active screen. 261 + */ 229 262 handleScroll() { 230 263 if (!this.isRecording) { 231 264 return; ··· 236 269 this.logEvent('scroll', 'scroll', null, metrics); 237 270 } 238 271 272 + /** 273 + * Records keyboard activity and correction counts. 274 + */ 239 275 handleKeyDown(event) { 240 276 if (!this.isRecording) { 241 277 return; ··· 254 290 this.logEvent('key', `key:${this.stats.keyboard.lastKey}`, event, metrics); 255 291 } 256 292 293 + /** 294 + * Appends a normalized interaction event to the session log. 295 + */ 257 296 logEvent(type, message, event, metrics, extra = {}) { 258 297 const payload = { 259 298 timestamp: Date.now(), ··· 276 315 } 277 316 } 278 317 318 + /** 319 + * Returns cloned interaction counters. 320 + */ 279 321 getStats() { 280 322 return { 281 323 mouse: { ...this.stats.mouse }, ··· 284 326 }; 285 327 } 286 328 329 + /** 330 + * Returns recorded interaction events. 331 + */ 287 332 getEvents() { 288 333 return [...this.events]; 289 334 } 290 335 336 + /** 337 + * Returns dense cursor trace samples. 338 + */ 291 339 getMouseTrace() { 292 340 return [...this.mouseTrace]; 293 341 } 294 342 343 + /** 344 + * Serializes tracker output for session artifacts. 345 + */ 295 346 exportData() { 296 347 return { 297 348 stats: this.getStats(),
+12
js/winConditions.js
··· 1 + /** 2 + * Creates the standardized iframe postMessage completion listener. 3 + */ 1 4 function createPostMessageEvaluator() { 2 5 return { 3 6 handler: null, ··· 30 33 } 31 34 32 35 export class WinConditionRegistry { 36 + /** 37 + * Creates a registry with no active completion listener. 38 + */ 33 39 constructor() { 34 40 this.activeEvaluator = null; 35 41 } 36 42 43 + /** 44 + * Starts listening for the active iframe's completion message. 45 + */ 37 46 start(context) { 38 47 this.stop(); 39 48 this.activeEvaluator = createPostMessageEvaluator(); 40 49 this.activeEvaluator.start(context); 41 50 } 42 51 52 + /** 53 + * Stops any active completion listener. 54 + */ 43 55 stop() { 44 56 if (this.activeEvaluator) { 45 57 this.activeEvaluator.stop();