···2222 return exact;
2323 }
2424 }
2525+ // Imported sessions may lack fingerprints, so fall back from identity to geometry.
2526 const containing = elements.find((element) => pointInRect(click, element.rect));
2627 if (containing) {
2728 return containing;
···6768 const metrics = [];
68696970 screens.forEach((screen) => {
7171+ // Limit candidates so broad pages do not let incidental DOM nodes dominate analysis time.
7072 const elements = screen.elementSnapshots
7173 .filter((element) => element.visible !== false && rectArea(element.rect) > 0 && isActionable(element))
7274 .slice(0, 120);
···9294 });
93959496 elements.forEach((element) => {
9797+ // Near-rectangle matching tolerates webcam and pointer imprecision around small controls.
9598 const nearbyFixations = screenFixations.filter((fixation) => {
9699 const point = { docX: fixation.x, docY: fixation.y };
97100 return pointInRect(point, element.rect) || distanceToRect(point, element.rect) <= NEAR_DISTANCE;
+5-1
js/analysisFindings.js
···88}
991010function priority({ severity, confidence, scope, recurrence = 0, affectedTime = 0, dataQualityPenalty = 0 }) {
1111+ // Priority is a sorting aid, not a statistical probability.
1112 return clamp(Math.round(
1213 (SEVERITY_WEIGHT[severity] || 0) +
1314 (CONFIDENCE_WEIGHT[confidence] || 0) +
···2728}
28292930function qualityPenalty(confidence) {
3131+ // Low-confidence sessions should still surface signals, but they should rank below cleaner evidence.
3032 if (confidence.score < 45) {
3133 return 25;
3234 }
···4951 const dataPenalty = qualityPenalty(confidence);
5052 const fastAction = hasFastAction(globalMetrics);
51535454+ // Fast first action suppresses speculative gaze-only friction later in this function.
5255 if (fastAction) {
5356 findings.push(finding({
5457 id: 'fast-first-action',
···130133 }
131134132135 elementMetrics.forEach((element) => {
136136+ // Element-level findings are intentionally stricter because they imply a specific UI target.
133137 if (element.repeatedClickCount >= 1) {
134138 findings.push(finding({
135139 id: 'element-repeated-clicks',
···375379 }
376380 });
377381382382+ // Quality warnings are rendered through the same finding UI so limitations stay visible in exports and reports.
378383 warnings.forEach((warning) => {
379384 findings.push(finding({
380385 id: `quality-${warning.code}`,
···444449 confidenceScore: confidence.score
445450 };
446451}
447447-
+7
js/analysisMetrics.js
···4141 const fixations = [];
4242 let current = null;
43434444+ // Fixations are approximated as one moving centroid per screen until gaze drifts too far away.
4445 const finalize = (endTime) => {
4546 if (!current) {
4647 return;
···116117 const buckets = new Map();
117118 const cols = 4;
118119 const rows = 4;
120120+ // A coarse grid is enough to describe spread without pretending pixel-level precision.
119121 points.forEach((point) => {
120122 const col = clamp(Math.floor((point.docX / width) * cols), 0, cols - 1);
121123 const row = clamp(Math.floor((point.docY / height) * rows), 0, rows - 1);
···199201 if (current.screenKey !== previous.screenKey) {
200202 continue;
201203 }
204204+ // Tight time and distance bounds catch repeated attempts without labeling normal double-clicks everywhere.
202205 const withinTime = current.timestamp - previous.timestamp <= 1200;
203206 const withinDistance = Math.hypot((current.docX || 0) - (previous.docX || 0), (current.docY || 0) - (previous.docY || 0)) <= 36;
204207 if (withinTime && withinDistance) {
···212215 const byScreen = groupBy(fixations, (fixation) => fixation.screenKey);
213216 const latencies = clicks.map((click) => {
214217 const candidates = byScreen.get(click.screenKey) || [];
218218+ // Walk backwards so the closest prior look wins instead of an older fixation in the same area.
215219 for (let index = candidates.length - 1; index >= 0; index -= 1) {
216220 const fixation = candidates[index];
217221 if (fixation.endTime > click.timestamp) {
···296300 if (segmentDistance < 12 && dt > 1200) {
297301 idleHesitationWindows += 1;
298302 }
303303+ // Movement near a click is intentional navigation; movement away from actions is treated as possible searching.
299304 const nearAction = clicks.some((click) => Math.abs(click.timestamp - current.timestamp) <= 1200);
300305 if (!nearAction) {
301306 deadMovement += segmentDistance;
···316321 if (leading.length < 2) {
317322 return null;
318323 }
324324+ // Ratio above 1 means the pointer path was less direct than a straight line to the click.
319325 const path = computeScanpathLength(leading);
320326 const direct = Math.hypot(leading[0].docX - (click.docX || 0), leading[0].docY - (click.docY || 0));
321327 return direct > 0 ? path / direct : 1;
···369375 if (event.type !== 'scroll') {
370376 return true;
371377 }
378378+ // Scroll can be observed on both window and document; identical snapshots should count once.
372379 const key = [
373380 event.type,
374381 event.timestamp,
+3
js/analysisRenderer.js
···3939 }
40404141 return findings.map((finding) => {
4242+ // All finding text is escaped here because analysis can include imported session labels.
4243 const evidence = (finding.evidence || []).map((item) => `<li>${formatEvidence(item)}</li>`).join('');
4344 const target = finding.elementFingerprint
4445 ? `<p class="analysis-target">Element: ${escapeHtml(finding.elementFingerprint)}</p>`
···169170170171 renderCohort({ cohortAnalysis, importErrors = [] }) {
171172 const warnings = [...cohortAnalysis.warnings];
173173+ // Import errors become quality warnings so partial cohort imports remain transparent.
172174 importErrors.forEach((error) => {
173175 warnings.push({
174176 code: `import-failed-${error.fileName}`,
···263265 }
264266265267 renderSessionComparison({ cohortAnalysis }) {
268268+ // Live same-app comparisons append to the single-session summary instead of replacing it.
266269 const repeated = cohortAnalysis.repeatedFindings.slice(0, 3).map((item) => `
267270 <li>${escapeHtml(item.title)} appeared in ${escapeHtml(item.sessionCount)} sessions (${escapeHtml(item.share)}%).</li>
268271 `).join('');
+3
js/calibration.js
···7272 await new Promise((resolve) => window.setTimeout(resolve, 220));
73737474 const target = this.getTargetPixelPosition(point);
7575+ // Score only samples collected since this point became active; earlier calibration noise is ignored.
7576 const samples = this.gazeTracker.getCalibrationSamples(
7677 this.currentPointStart,
7778 Date.now() + this.sampleWindowMs
7879 );
8080+ // Off-iframe samples are not useful because target positions are measured in the UXET viewport.
7981 const inIframeSamples = samples.filter((sample) => sample.inIframe);
8082 const error = inIframeSamples.length
8183 ? Math.round(
···140142 getTargetPixelPosition(point) {
141143 const viewport = this.getViewportRect();
142144 const insets = this.getSafeAreaInsets();
145145+ // Insets keep edge targets away from the HUD and browser chrome-sensitive corners.
143146 const width = Math.max(0, viewport.width - insets.left - insets.right);
144147 const height = Math.max(0, viewport.height - insets.top - insets.bottom);
145148
+4
js/cohortAnalyzer.js
···1313 const screen = finding.screenKey
1414 ? artifact.screens.find((item) => item.key === finding.screenKey)
1515 : null;
1616+ // Group by finding type plus target context so unrelated screens do not look like repeated evidence.
1617 return [
1718 finding.id,
1819 finding.elementFingerprint || '',
···4849 const appNames = unique(artifacts.map((artifact) => artifact.session.appName));
4950 const tasks = unique(artifacts.map((artifact) => artifact.session.task));
5051 const mixedAppOrTask = appNames.length > 1 || unique(tasks.map(normalizeText)).length > 1;
5252+ // Small cohorts still need at least directional repetition without requiring a large sample size.
5153 const threshold = Math.min(2, Math.max(1, Math.ceil(artifacts.length * 0.4)));
5254 const findingGroups = new Map();
5355 const elementGroups = new Map();
···109111 totalRepeatedClicks: items.reduce((sum, item) => sum + item.element.repeatedClickCount, 0)
110112 };
111113 })
114114+ // Only surface repeated element patterns that include some friction-like signal.
112115 .filter((pattern) => pattern.totalRepeatedClicks > 0 || pattern.medianLookBeforeClickRate < 60 || pattern.medianPostClickConfirmationRate < 60)
113116 .sort((a, b) => b.totalRepeatedClicks - a.totalRepeatedClicks || a.medianLookBeforeClickRate - b.medianLookBeforeClickRate);
114117···127130 attentionFrictionScore: analysis.globalMetrics.attentionFrictionScore,
128131 interactionFrictionScore: analysis.globalMetrics.interactionFrictionScore,
129132 confidenceScore: analysis.confidence.score,
133133+ // Pick one plain-language reason so the report stays scannable.
130134 reason: analysis.globalMetrics.timeToFirstAction >= 5000
131135 ? 'First action was delayed after pre-action exploration.'
132136 : analysis.globalMetrics.duration > medianDuration * 1.8
+4
js/debriefRenderer.js
···88}
991010function heatmapColor(t) {
1111+ // Blue-to-red makes low-intensity areas visible without overpowering the screenshot.
1112 if (t < 0.25) {
1213 const f = t / 0.25;
1314 return [0, Math.round(f * 180), Math.round(200 + f * 55)];
···4243 ctx.drawImage(image, 0, 0, width, height);
4344 }
44454646+ // Build intensity on a separate canvas so colorization can normalize against the actual max alpha.
4547 const heatCanvas = document.createElement('canvas');
4648 heatCanvas.width = width;
4749 heatCanvas.height = height;
···68706971 for (let index = 0; index < data.length; index += 4) {
7072 const intensity = data[index + 3] / maxAlpha;
7373+ // Drop the faint edge of each radial gradient to keep the overlay readable.
7174 if (intensity < 0.02) {
7275 data[index + 3] = 0;
7376 continue;
···114117 return;
115118 }
116119120120+ // A screen is useful if it has either a screenshot or enough gaze data to explain what is missing.
117121 const renderableScreens = screens.filter((screen) => {
118122 const hasScreenshot = screen.screenshot.status === 'ready' && screen.screenshot.dataUrl;
119123 const usablePoints = getUsablePoints(screen);
+7
js/gazeTracker.js
···95959696 try {
9797 window.saveDataAcrossSessions = false;
9898+ // WebGazer loads MediaPipe assets lazily, so keep the path explicit for local static serving.
9899 webgazer.params.faceMeshSolutionPath = 'https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh';
99100 webgazer
100101 .setRegression('ridge')
···204205 inIframe: false
205206 };
206207208208+ // Raw samples are kept during calibration even before they become recorded gaze points.
207209 this.rawSamples.push(sample);
208210 if (this.rawSamples.length > 400) {
209211 this.rawSamples.shift();
···216218 this.stats.lastGazeX = sample.viewportX;
217219 this.stats.lastGazeY = sample.viewportY;
218220221221+ // WebGazer reports parent viewport coordinates; analysis needs iframe document coordinates.
219222 const metrics = this.currentMetrics;
220223 const iframeX = sample.viewportX - metrics.iframeLeft;
221224 const iframeY = sample.viewportY - metrics.iframeTop;
···242245243246 this.rawSamples[this.rawSamples.length - 1] = fullSample;
244247248248+ // Throttle accepted points to keep exports and heatmaps usable on long sessions.
245249 if (this.mode === 'recording' && now - this.lastAcceptedAt >= this.logInterval) {
246250 this.lastAcceptedAt = now;
247251 this.acceptGazePoint(fullSample);
···308312 const distance = Math.hypot(dx, dy);
309313 const sameScreen = point.screenKey === this.currentFixation.screenKey;
310314315315+ // Use a running centroid so fixation centers are less sensitive to single noisy samples.
311316 if (sameScreen && distance <= this.fixationThreshold) {
312317 this.currentFixation.pointCount += 1;
313318 this.currentFixation.x =
···439444 if (typeof webgazer !== 'undefined') {
440445 try {
441446 webgazer.pause();
447447+ // WebGazer does not consistently release camera tracks across browsers.
442448 const videoElement = webgazer.getVideoElement ? webgazer.getVideoElement() : null;
443449 const stream = videoElement?.srcObject || webgazer.params?.videoStream || null;
444450 if (stream?.getTracks) {
···449455 console.warn('[GazeTracker] Failed to end WebGazer cleanly:', error);
450456 }
451457 }
458458+ // Clean up hidden WebGazer video nodes so repeated tests do not accumulate camera elements.
452459 document.querySelectorAll('#webgazerVideoContainer, video').forEach((element) => {
453460 if (element.id === 'webgazerVideoContainer' || element.dataset?.webgazerVideoFeed) {
454461 element.remove();
+5
js/iframeBridge.js
···4747 const restoreFns = [];
48484949 images.forEach((image) => {
5050+ // Full-page screenshots need lazy images loaded before html2canvas clones the document.
5051 if (image.loading === 'lazy') {
5152 const previousLoading = image.loading;
5253 image.loading = 'eager';
···126127 methods.forEach((methodName) => {
127128 const original = history[methodName].bind(history);
128129 history[methodName] = (...args) => {
130130+ // SPA route changes do not trigger iframe load, so patch history to refresh screen keys.
129131 const result = original(...args);
130132 this.refreshMetrics(true);
131133 this._stableNotifier();
···196198 const docEl = doc.documentElement;
197199 const body = doc.body;
198200 const rect = this.iframe.getBoundingClientRect();
201201+ // The screen key intentionally follows URL path/search/hash instead of DOM title.
199202 const key = `${win.location.pathname}${win.location.search}${win.location.hash}`;
200203 const metrics = {
201204 key,
···326329 }
327330328331 const doc = this.iframeDocument;
332332+ // Capture semantic controls plus app-specific targets without requiring test apps to add UXET hooks.
329333 const selector = 'a, button, input, select, textarea, label, [role], [data-uxet], [data-id], [onclick], [tabindex]';
330334 const candidateSet = new Set(Array.from(doc.querySelectorAll(selector)));
331335 Array.from(doc.body?.querySelectorAll('*') || []).forEach((element) => {
···344348 const clickable = element.matches(selector) || style.cursor === 'pointer';
345349 const ancestor = element.closest('[data-id], [role], a, button, [onclick], [tabindex]');
346350 return {
351351+ // Fingerprints are stable enough for a session, but not treated as permanent DOM IDs.
347352 fingerprint: [
348353 element.tagName.toLowerCase(),
349354 element.id || '',
+1
js/importer.js
···2525 const results = [];
2626 const errors = [];
27272828+ // Partial success is useful during cohort imports: valid sessions can still be analyzed.
2829 for (const file of Array.from(files || [])) {
2930 try {
3031 results.push({
+9
js/main.js
···233233 if (!this.debugState.mouseGazeMode || this.session.state !== 'recording') {
234234 return;
235235 }
236236+ // Mouse-as-gaze uses the same document coordinate shape as WebGazer samples.
236237 this.gazeTracker.ingestSyntheticGaze({
237238 timestamp: payload.timestamp,
238239 viewportX: Math.round(payload.metrics.iframeLeft + payload.clientX),
···282283 this.elements.appSelect.innerHTML = '<option value="">Loading apps...</option>';
283284284285 try {
286286+ // App metadata is runtime-discovered so index.html does not need hard-coded test targets.
285287 const { apps, errors } = await discoverTestableApps();
286288 this.availableApps = apps;
287289 this.elements.appSelect.innerHTML = [
···351353 this.syncDebugUi();
352354353355 if (enabled) {
356356+ // Mouse mode is a deliberate calibration bypass, not a passed eye-tracking calibration.
354357 this.calibrationSkippedByDebug = true;
355358 this.calibrationPassed = false;
356359 this.lastCalibrationResult = null;
···425428 return;
426429 }
427430431431+ // Wait for layout to settle before converting viewport gaze into iframe document coordinates.
428432 await this.forceMetricsRefresh();
429433 const metrics = this.bridge.getMetricsSnapshot();
430434 this.gazeTracker.updateMetrics(metrics);
···548552 return this.captureJobs.get(screenKey);
549553 }
550554555555+ // Screenshot capture is async and expensive; share the same promise for repeated stable events.
551556 const job = (async () => {
552557 try {
553558 this.gazeTracker.setElementSnapshots(screenKey, this.bridge.captureElementSnapshots());
···629634630635 this.analysisRenderer.render({ artifact, analysis });
631636 const sameAppHistory = this.getSameAppHistory();
637637+ // Repeated live runs are treated as a temporary same-app cohort until reset/reload.
632638 if (artifact.source === 'live' && sameAppHistory.length >= 2) {
633639 const cohortAnalysis = analyzeSessionCohort(
634640 sameAppHistory.map((entry) => entry.artifact),
···756762 if (results.length > 1) {
757763 const artifacts = results.map((result) => result.artifact);
758764 const analyses = artifacts.map((artifact) => analyzeSessionArtifact(artifact));
765765+ // Multi-file import is cohort mode; there is no single heatmap gallery to render.
759766 const cohortAnalysis = analyzeSessionCohort(artifacts, { analyses });
760767 this.latestArtifact = null;
761768 this.latestAnalysis = null;
···912919 }
913920914921 async forceMetricsRefresh() {
922922+ // Two animation frames lets iframe layout and scroll metrics settle after load or resize.
915923 await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
916924 const metrics = this.bridge.refreshMetrics(false);
917925 if (metrics) {
···931939 }
932940933941 updateUiForState(state, details = {}) {
942942+ // The session stage hides the tested app until calibration is complete and recording starts.
934943 const sessionStates = new Set(['calibrating', 'calibration_failed', 'ready_to_start', 'recording', 'finishing']);
935944 const sessionActive = sessionStates.has(state);
936945
+1
js/session.js
···133133 }
134134135135 exportSession(payload) {
136136+ // Kept as a fallback for older call sites; the main export path writes schema v3.
136137 const data = {
137138 schemaVersion: '2',
138139 session: this.getMetadata(),
+10
js/sessionAnalyzer.js
···9797function buildConfidence(artifact, warnings) {
9898 let score = 100;
9999 const reasons = [];
100100+ // Confidence is intentionally conservative: each data-quality issue reduces trust in all downstream claims.
100101 warnings.forEach((warning) => {
101102 if (warning.severity === 'high') {
102103 score -= 20;
···134135 }
135136 });
136137138138+ // Revisiting any prior screen is treated as backtracking, even if the route was not strictly browser history.
137139 let backtracks = 0;
138140 const seen = new Set();
139141 screenSequence.forEach((key) => {
···148150function computeRageClickCandidates(clicks) {
149151 const candidates = [];
150152 for (let index = 2; index < clicks.length; index += 1) {
153153+ // Three close clicks inside 1.8s is a conservative signal of repeated failed activation.
151154 const group = [clicks[index - 2], clicks[index - 1], clicks[index]];
152155 const sameScreen = group.every((click) => click.screenKey === group[0].screenKey);
153156 const withinTime = group[2].timestamp - group[0].timestamp <= 1800;
···176179}
177180178181function computeAnalysisStartTime({ points, events, fixations }) {
182182+ // Imported legacy sessions can be missing one or more streams, so start from the earliest available evidence.
179183 const candidates = [
180184 points[0]?.timestamp,
181185 events[0]?.timestamp,
···191195 ]));
192196 const seen = new Set();
193197198198+ // Count distinct AOIs inspected before acting, not repeated looks at the same control.
194199 fixations.forEach((fixation) => {
195200 const elements = elementsByScreen.get(fixation.screenKey) || [];
196201 const point = { docX: fixation.x, docY: fixation.y };
···229234 const preActionScrollDepth = computeScrollDepth(preActionEvents, artifact.screens);
230235 const preActionScanpathLength = computeScanpathLength(preActionPoints);
231236 const timeToFirstAction = actionTime ? Math.max(0, actionTime - analysisStartTime) : 0;
237237+ // This blends time, spread, element coverage, and scrolling into one directional exploration score.
232238 const preActionExplorationScore = clamp(Math.round(
233239 (preActionUniqueZones * 7) +
234240 (preActionUniqueElements * 6) +
···253259}
254260255261function classifyBehavior({ globalMetrics, clicks, confidence }) {
262262+ // The outcome label gates findings so a quick successful path is not over-explained as friction.
256263 if (confidence.score < 35 || (!globalMetrics.totalFixations && !globalMetrics.totalClicks)) {
257264 return 'inconclusive';
258265 }
···300307 const timeToFirstFixation = firstFixation
301308 ? Math.max(0, firstFixation.startTime - analysisStartTime)
302309 : 0;
310310+ // Screen friction is intentionally screen-local; it should not override the session-level outcome by itself.
303311 const frictionScore = clamp(Math.round(
304312 (gazeEntropy * 14) +
305313 (revisitCount * 8) +
···345353 const mouseTrace = [...artifact.interactions.mouseTrace].sort((a, b) => a.timestamp - b.timestamp);
346354 const clicks = events.filter((event) => event.type === 'click');
347355 const scrolls = events.filter((event) => event.type === 'scroll');
356356+ // Live schema v3 exports include fixations; legacy imports can still be analyzed by deriving them.
348357 const fixations = artifact.gaze.fixations?.length
349358 ? [...artifact.gaze.fixations].sort((a, b) => a.startTime - b.startTime)
350359 : detectFixations(points);
···360369 const lookThenActRate = computeLookThenActRate(clicks, fixations);
361370 const preActionMetrics = computePreActionMetrics({ artifact, points, fixations, events, analysisStartTime });
362371 const timeToFirstMeaningfulAction = preActionMetrics.timeToFirstAction;
372372+ // These scores are heuristic signals for ranking findings, not validated clinical UX metrics.
363373 const attentionFrictionScore = clamp(Math.round(
364374 Math.min(preActionMetrics.timeToFirstAction / 160, 55) +
365375 (preActionMetrics.preActionUniqueZones * 4) +
+6
js/sessionArtifact.js
···160160 width: asNumber(documentInfo.width, asNumber(source.documentWidth)),
161161 height: asNumber(documentInfo.height, asNumber(source.documentHeight))
162162 },
163163+ // Older exports stored screenshot metadata as flat fields; v3 stores it as an object.
163164 screenshot: {
164165 dataUrl: typeof screenshot.dataUrl === 'string' ? screenshot.dataUrl : null,
165166 width: asNumber(screenshot.width, asNumber(source.screenshotWidth)),
···199200 if (map.has(screenKey)) {
200201 return map.get(screenKey);
201202 }
203203+ // Legacy exports can have points/events but no screen records; synthesize a minimal screen.
202204 const inferred = normalizeScreenRecord({ key: screenKey, title: screenKey });
203205 map.set(screenKey, inferred);
204206 return inferred;
···216218}
217219218220function buildFidelity({ screens, mouseTrace, points, debug }) {
221221+ // Fidelity flags let analysis explain missing evidence instead of silently lowering output quality.
219222 return {
220223 hasScreenshots: screens.some((screen) => screen.screenshot.status === 'ready' && screen.screenshot.dataUrl),
221224 hasMouseTrace: mouseTrace.length > 0,
···238241 const interactionEvents = sortByTimestamp(asArray(interactions?.events).map(normalizeInteractionEvent));
239242 const mouseTrace = sortByTimestamp(asArray(interactions?.mouseTrace).map(normalizeMouseTracePoint));
240243 const fixations = sortByTimestamp(asArray(gaze?.fixations).map(normalizeFixation), 'startTime');
244244+ // Normalize live data through the same path as imports so the analyzer sees one schema.
241245 const screens = enrichScreensWithPoints(
242246 asArray(screenRecords).map(normalizeScreenRecord),
243247 gazePoints,
···286290 const gaze = asObject(source.gaze);
287291 const interactions = asObject(source.interactions);
288292 const analysis = asObject(source.analysis);
293293+ // Some schema-v3 files may contain only embedded analysis; mark them so confidence reflects that.
289294 const analysisContext = {
290295 ...asObject(source.analysisContext),
291296 embeddedAnalysisOnly: Boolean(schemaVersion === '3' && source.analysis && (!source.gaze || !source.interactions))
···299304 const fixations = sortByTimestamp(asArray(source.fixations || gaze.fixations).map(normalizeFixation), 'startTime');
300305301306 let screens = [];
307307+ // Accept all historical screen shapes UXET has exported.
302308 if (Array.isArray(importedScreenRecords)) {
303309 screens = importedScreenRecords.map(normalizeScreenRecord);
304310 } else if (Array.isArray(importedScreens)) {
+4
js/testableApps.js
···66 return null;
77 }
8899+ // Directory-listing links are usually relative to /testable-apps/, not to the UXET page.
910 const rootUrl = new URL(TESTABLE_APPS_ROOT, window.location.href);
1011 const url = new URL(href, rootUrl.href);
1112 if (!url.href.startsWith(rootUrl.href) || !url.pathname.endsWith('/')) {
···2223}
23242425function parseDirectoryListing(html) {
2626+ // This intentionally relies on the simple HTML index produced by python -m http.server.
2527 const document = new DOMParser().parseFromString(html, 'text/html');
2628 return Array.from(document.querySelectorAll('a[href]'))
2729 .map((link) => normalizeDirectoryHref(link.getAttribute('href')))
···5355}
54565557export async function discoverTestableApps() {
5858+ // Static sites cannot read local folders directly; the local HTTP directory index is the discovery API.
5659 const response = await fetch(TESTABLE_APPS_ROOT, { cache: 'no-store' });
5760 if (!response.ok) {
5861 throw new Error(`Failed to inspect ${TESTABLE_APPS_ROOT}: ${response.status}`);
···6366 throw new Error(`No app folders were found in ${TESTABLE_APPS_ROOT}.`);
6467 }
65686969+ // One bad app.json should not hide every other valid app from the tester.
6670 const results = await Promise.allSettled(directories.map(async (directoryName) => {
6771 const metadata = await fetchJson(`${TESTABLE_APPS_ROOT}${encodeURIComponent(directoryName)}/${APP_METADATA_FILE}`);
6872 return normalizeMetadata(directoryName, metadata);
+3
js/tracker.js
···135135 });
136136 }
137137138138+ // Dense mouse trace feeds movement metrics; the event log below stays lower-volume.
138139 if (now - this.lastTraceAt >= this.mouseTraceInterval) {
139140 const velocity = this.velocities.length ? this.velocities[this.velocities.length - 1] : 0;
140141 this.mouseTrace.push({
···176177 target?.innerText?.trim?.()?.slice(0, 80) ||
177178 target?.value ||
178179 '';
180180+ // Fingerprints bridge click events back to element snapshots when geometry is ambiguous.
179181 const clickTargetFingerprint = [
180182 tagName,
181183 target?.id || '',
···185187 ].filter(Boolean).join('|');
186188 const targetPath = [];
187189 let cursor = target;
190190+ // A short ancestor path helps debug delegated click handlers without exporting the whole DOM.
188191 while (cursor && targetPath.length < 4 && cursor !== this.bridge.iframeDocument.body) {
189192 const rect = cursor.getBoundingClientRect?.();
190193 const label = cursor.getAttribute?.('aria-label') ||
+1
js/winConditions.js
···44 start({ bridge, complete, session }) {
55 this.stop();
66 this.handler = (event) => {
77+ // Only the active iframe is allowed to complete the task.
78 if (session.state !== 'recording') {
89 return;
910 }