Aryaman25 Claude Sonnet 5 commited on
Commit
0f6fea3
·
1 Parent(s): 1b1f57b

Add tabbed heatmaps, full-session export, adaptive chrome, element chart

Browse files

Recent Sessions on the landing page now map to their actual generated
report (session-id -> analysis_report.html, reusing analysis.py's own
load_session()/session_summary() for domain/duration/sample-count) instead
of being static, unclickable timestamp rows. Report's "Back to Browser"
now navigates directly to the tracked page's own URL (already recorded
per-session) instead of leaning on window.history, which is unreliable to
reason about after a Python-triggered navigation in an embedded webview.

Sidebar and toolbar now auto-collapse into a compact bar the moment a
session starts (reusing the sidebar's existing collapse mode + a new
toolbar compact mode), maximizing space for the tracked page, with a
small always-visible toggle to reveal full controls temporarily; both
restore automatically when tracking stops.

Report: removed the Scroll Depth stat card (kept compute_scroll_depth()
itself untouched, just stopped surfacing it) and added a Gaze Samples
card in its place; enriched the header with session timestamp and
duration alongside the URL; added a styled error+Retry state for
screenshots that fail to load and Retry actions on toast failures for
full-session export, instead of things just silently not working.

Investigated the "Mouse Events: 0 / no clicks recorded" report: dwell
(hover) data and click data are collected separately in MOUSE_JS -- hover
tracking (getSmartLabel, populates "Most Interacted Elements") has no
element-type restriction, but click logging is gated by isInteractable(),
which only records clicks on genuine links/buttons/inputs/cursor:pointer
elements. That gate is mouse-tracking data-collection logic, out of scope
for this frontend-only pass, so it was left untouched; this is a data-
availability characteristic, not a frontend rendering bug -- the click
log correctly displays whatever clicks the existing collector captured.

No tracking, calibration, validation, screenshot-capture, or analytics
code was touched -- confined entirely to browser_session.py's landing
page/chrome and analysis.py's report template.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Files changed (2) hide show
  1. analysis.py +76 -20
  2. browser_session.py +119 -14
analysis.py CHANGED
@@ -18,6 +18,7 @@ import os
18
  import re
19
  import json
20
  import bisect
 
21
 
22
  import theme
23
 
@@ -510,7 +511,7 @@ __THEME_CSS__
510
  <div class="mark">__EYE_ICON__</div>
511
  <div>
512
  <h1>InsightUX Session Report</h1>
513
- <div class="sub">__URL__ &nbsp;&middot;&nbsp; __SAMPLES__ gaze samples</div>
514
  </div>
515
  </div>
516
  <div class="actions">
@@ -526,7 +527,7 @@ __THEME_CSS__
526
  <div class="stat iux-card hoverable"><div class="icon-badge">__LAYERS_ICON__</div><div class="v">__NUM_ELEMENTS__</div><div class="l">Elements Fixated</div></div>
527
  <div class="stat iux-card hoverable"><div class="icon-badge">__TARGET_ICON__</div><div class="v" style="font-size:14px;">__TOP_LABEL__</div><div class="l">Most Attended</div></div>
528
  <div class="stat iux-card hoverable"><div class="icon-badge">__CURSOR_ICON__</div><div class="v">__CLICK_COUNT__</div><div class="l">Mouse Events</div></div>
529
- <div class="stat iux-card hoverable"><div class="icon-badge">__CHEVRON_ICON__</div><div class="v">__SCROLL_DEPTH__%</div><div class="l">Scroll Depth</div></div>
530
  </div>
531
 
532
  <div class="grid">
@@ -665,14 +666,23 @@ document.getElementById('btnFilter').addEventListener('click', function(){
665
  input.focus();
666
  });
667
 
668
- // ---------- Back to Browser: pure client-side history navigation, never
669
- // touches the Api/tracking state — just returns to the previously loaded
670
- // page in this same pywebview window, same as a normal browser back. ----------
 
 
 
 
 
671
  document.getElementById('backNav').addEventListener('click', function(){
672
- if (window.history.length > 1) {
 
 
673
  window.history.back();
674
  } else if (document.referrer) {
675
  window.location.href = document.referrer;
 
 
676
  }
677
  });
678
 
@@ -844,7 +854,7 @@ function iconForLabel(label){
844
 
845
  const clickEl = document.getElementById('mouseClicks');
846
  if (!MOUSE_CLICKS.length) {
847
- clickEl.innerHTML = '<div class="empty"><span class="ei">' + iuxIcon('click', 26) + '</span>No clicks recorded for this session.</div>';
848
  } else {
849
  let html = '<ul style="padding-left:0;margin:0;list-style:none;">';
850
  MOUSE_CLICKS.slice().reverse().forEach(c => {
@@ -992,8 +1002,9 @@ function loadImage(src){
992
 
993
  // Minimal toast so a failed export/viewer action is visible instead of
994
  // silently doing nothing — reuses the .iux-toast style already shared with
995
- // the toolbar/landing page design system.
996
- function showToast(message, isError){
 
997
  let toast = document.getElementById('iuxReportToast');
998
  if (!toast) {
999
  toast = document.createElement('div');
@@ -1005,14 +1016,27 @@ function showToast(message, isError){
1005
  toast.style.transform = 'translateX(-50%)';
1006
  toast.style.zIndex = '3000000';
1007
  toast.style.display = 'none';
 
1008
  document.body.appendChild(toast);
1009
  }
1010
- toast.textContent = message;
 
 
 
 
 
 
 
 
 
 
 
1011
  toast.style.borderColor = isError ? 'var(--iux-danger)' : 'var(--iux-border)';
1012
  toast.style.color = isError ? 'var(--iux-danger)' : 'var(--iux-text)';
1013
  toast.style.display = 'flex';
 
1014
  clearTimeout(toast._hideTimer);
1015
- toast._hideTimer = setTimeout(function(){ toast.style.display = 'none'; }, 3200);
1016
  }
1017
 
1018
  // ---------- Screenshot-backed heatmaps: Eye / Mouse / Combined tabs, paginated ----------
@@ -1110,7 +1134,10 @@ function showToast(message, isError){
1110
  if (i !== cur) { cur = i; renderSegment(cur); }
1111
  renderPromise.then(function(){
1112
  const canvas = composeSegmentCanvas();
1113
- if (!canvas) { showToast('Could not prepare this screenshot for viewing.', true); return; }
 
 
 
1114
  Viewer.open({
1115
  source: canvas,
1116
  title: segmentTitle(i),
@@ -1123,8 +1150,12 @@ function showToast(message, isError){
1123
 
1124
  document.getElementById('hmFullscreen').addEventListener('click', function(){ openSegmentViewer(cur); });
1125
  document.getElementById('hmDownload').addEventListener('click', function(){
 
1126
  const canvas = composeSegmentCanvas();
1127
- if (!canvas) { showToast('Could not prepare this screenshot for export.', true); return; }
 
 
 
1128
  try {
1129
  const a = document.createElement('a');
1130
  a.href = canvas.toDataURL('image/png');
@@ -1172,7 +1203,22 @@ function showToast(message, isError){
1172
  paintActive(ctx, cv.width, cv.height);
1173
  resolve();
1174
  }
1175
- if (img.complete && img.naturalWidth) paint(); else img.onload = paint;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1176
  });
1177
  return renderPromise;
1178
  }
@@ -1188,14 +1234,14 @@ function showToast(message, isError){
1188
  const imgs = await Promise.all(SEGMENTS.map(s => loadImage(s.screenshot)));
1189
  const valid = imgs.filter(Boolean);
1190
  if (!valid.length) {
1191
- showToast('Could not load this session’s screenshots for a full-page export.', true);
1192
  return;
1193
  }
1194
  const width = valid[0].naturalWidth;
1195
  let height = 0;
1196
  SEGMENTS.forEach((s, i) => { if (imgs[i]) height = Math.max(height, s.scrollY + imgs[i].naturalHeight); });
1197
  if (!height) {
1198
- showToast('Could not determine the page size for this export.', true);
1199
  return;
1200
  }
1201
 
@@ -1231,10 +1277,10 @@ function showToast(message, isError){
1231
  title: 'Full Session — ' + exportMode.charAt(0).toUpperCase() + exportMode.slice(1) + ' Heatmap',
1232
  downloadName: 'insightux-full-session-' + exportMode + '-heatmap.png',
1233
  });
1234
- if (!opened) showToast('Could not open the ' + exportMode + ' heatmap viewer.', true);
1235
  } catch (err) {
1236
  console.warn('[insightux-report] full-session export failed:', exportMode, err);
1237
- showToast('Could not build the full-session ' + exportMode + ' heatmap.', true);
1238
  } finally {
1239
  if (btn) { btn.innerHTML = original; btn.disabled = false; }
1240
  }
@@ -1524,20 +1570,30 @@ def generate_report(session_dir):
1524
  segments = build_screenshot_segments(gaze, dom)
1525
  summary = session_summary(gaze, dom)
1526
  mouse = summarize_mouse(session_dir)
1527
- scroll_depth = compute_scroll_depth(dom)
1528
  mouse_points = mouse_heat_points(session_dir)
1529
  mouse_segments = build_mouse_screenshot_segments(segments, dom, session_dir)
1530
 
1531
  top_label = ranking[0]["label"] if ranking else "—"
1532
 
 
 
 
 
 
 
 
 
 
 
1533
  html = _TEMPLATE
1534
  html = html.replace("__URL__", summary["url"] or "Unknown page")
 
 
1535
  html = html.replace("__SAMPLES__", str(summary["samples"]))
1536
  html = html.replace("__DURATION__", str(summary["duration"]))
1537
  html = html.replace("__NUM_ELEMENTS__", str(len(ranking)))
1538
  html = html.replace("__TOP_LABEL__", top_label)
1539
  html = html.replace("__CLICK_COUNT__", str(mouse["click_count"]))
1540
- html = html.replace("__SCROLL_DEPTH__", str(scroll_depth))
1541
  html = html.replace("__RANKING_JSON__", json.dumps(ranking))
1542
  html = html.replace("__TIMELINE_JSON__", json.dumps(timeline))
1543
  html = html.replace("__SEGMENTS_JSON__", json.dumps(segments))
 
18
  import re
19
  import json
20
  import bisect
21
+ from datetime import datetime
22
 
23
  import theme
24
 
 
511
  <div class="mark">__EYE_ICON__</div>
512
  <div>
513
  <h1>InsightUX Session Report</h1>
514
+ <div class="sub">__URL__ &nbsp;&middot;&nbsp; __SESSION_TIMESTAMP__ &nbsp;&middot;&nbsp; __DURATION__s &nbsp;&middot;&nbsp; __SAMPLES__ gaze samples</div>
515
  </div>
516
  </div>
517
  <div class="actions">
 
527
  <div class="stat iux-card hoverable"><div class="icon-badge">__LAYERS_ICON__</div><div class="v">__NUM_ELEMENTS__</div><div class="l">Elements Fixated</div></div>
528
  <div class="stat iux-card hoverable"><div class="icon-badge">__TARGET_ICON__</div><div class="v" style="font-size:14px;">__TOP_LABEL__</div><div class="l">Most Attended</div></div>
529
  <div class="stat iux-card hoverable"><div class="icon-badge">__CURSOR_ICON__</div><div class="v">__CLICK_COUNT__</div><div class="l">Mouse Events</div></div>
530
+ <div class="stat iux-card hoverable"><div class="icon-badge">__TYPE_ICON__</div><div class="v">__SAMPLES__</div><div class="l">Gaze Samples</div></div>
531
  </div>
532
 
533
  <div class="grid">
 
666
  input.focus();
667
  });
668
 
669
+ // ---------- Back to Browser: navigates straight back to the website that
670
+ // was actually being tracked (same URL session_summary() already recorded
671
+ // for this session — the __URL__ shown in the header above) rather than
672
+ // leaning on window.history, whose stack can be unreliable to reason about
673
+ // inside an embedded webview after a Python-triggered navigation. Falls
674
+ // back to real history/referrer only if that URL is unknown. Pure
675
+ // client-side navigation — never touches the Api or tracking state. ----------
676
+ const SESSION_URL = __URL_JSON__;
677
  document.getElementById('backNav').addEventListener('click', function(){
678
+ if (SESSION_URL) {
679
+ window.location.href = SESSION_URL;
680
+ } else if (window.history.length > 1) {
681
  window.history.back();
682
  } else if (document.referrer) {
683
  window.location.href = document.referrer;
684
+ } else {
685
+ showToast('No previous page to return to.', true);
686
  }
687
  });
688
 
 
854
 
855
  const clickEl = document.getElementById('mouseClicks');
856
  if (!MOUSE_CLICKS.length) {
857
+ clickEl.innerHTML = '<div class="empty"><span class="ei">' + iuxIcon('click', 26) + '</span>No mouse clicks were recorded during this session.</div>';
858
  } else {
859
  let html = '<ul style="padding-left:0;margin:0;list-style:none;">';
860
  MOUSE_CLICKS.slice().reverse().forEach(c => {
 
1002
 
1003
  // Minimal toast so a failed export/viewer action is visible instead of
1004
  // silently doing nothing — reuses the .iux-toast style already shared with
1005
+ // the toolbar/landing page design system. Pass onRetry to add a Retry
1006
+ // action instead of the toast just auto-dismissing.
1007
+ function showToast(message, isError, onRetry){
1008
  let toast = document.getElementById('iuxReportToast');
1009
  if (!toast) {
1010
  toast = document.createElement('div');
 
1016
  toast.style.transform = 'translateX(-50%)';
1017
  toast.style.zIndex = '3000000';
1018
  toast.style.display = 'none';
1019
+ toast.style.gap = '12px';
1020
  document.body.appendChild(toast);
1021
  }
1022
+ toast.innerHTML = '<span></span>';
1023
+ toast.querySelector('span').textContent = message;
1024
+ if (onRetry) {
1025
+ const retryBtn = document.createElement('button');
1026
+ retryBtn.type = 'button';
1027
+ retryBtn.className = 'iux-btn';
1028
+ retryBtn.style.padding = '4px 10px';
1029
+ retryBtn.style.fontSize = '11.5px';
1030
+ retryBtn.textContent = 'Retry';
1031
+ retryBtn.addEventListener('click', function(){ toast.style.display = 'none'; clearTimeout(toast._hideTimer); onRetry(); });
1032
+ toast.appendChild(retryBtn);
1033
+ }
1034
  toast.style.borderColor = isError ? 'var(--iux-danger)' : 'var(--iux-border)';
1035
  toast.style.color = isError ? 'var(--iux-danger)' : 'var(--iux-text)';
1036
  toast.style.display = 'flex';
1037
+ toast.style.alignItems = 'center';
1038
  clearTimeout(toast._hideTimer);
1039
+ toast._hideTimer = setTimeout(function(){ toast.style.display = 'none'; }, onRetry ? 6000 : 3200);
1040
  }
1041
 
1042
  // ---------- Screenshot-backed heatmaps: Eye / Mouse / Combined tabs, paginated ----------
 
1134
  if (i !== cur) { cur = i; renderSegment(cur); }
1135
  renderPromise.then(function(){
1136
  const canvas = composeSegmentCanvas();
1137
+ if (!canvas) {
1138
+ showToast('Could not prepare this screenshot for viewing.', true, function(){ renderSegment(i); openSegmentViewer(i); });
1139
+ return;
1140
+ }
1141
  Viewer.open({
1142
  source: canvas,
1143
  title: segmentTitle(i),
 
1150
 
1151
  document.getElementById('hmFullscreen').addEventListener('click', function(){ openSegmentViewer(cur); });
1152
  document.getElementById('hmDownload').addEventListener('click', function(){
1153
+ const theCur = cur;
1154
  const canvas = composeSegmentCanvas();
1155
+ if (!canvas) {
1156
+ showToast('Could not prepare this screenshot for export.', true, function(){ renderSegment(theCur); });
1157
+ return;
1158
+ }
1159
  try {
1160
  const a = document.createElement('a');
1161
  a.href = canvas.toDataURL('image/png');
 
1203
  paintActive(ctx, cv.width, cv.height);
1204
  resolve();
1205
  }
1206
+ function fail(){
1207
+ const shotWrap = document.getElementById('shotWrap');
1208
+ if (shotWrap) {
1209
+ shotWrap.innerHTML =
1210
+ '<div class="empty"><span class="ei">' + iuxIcon('info', 26) + '</span>' +
1211
+ 'Could not load the screenshot for segment ' + (i+1) + '.<br>' +
1212
+ '<button type="button" class="iux-btn" id="retryShotBtn" style="margin-top:10px;">' +
1213
+ iuxIcon('refresh', 13) + ' Retry</button></div>';
1214
+ shotWrap.removeAttribute('title');
1215
+ const retryBtn = document.getElementById('retryShotBtn');
1216
+ if (retryBtn) retryBtn.addEventListener('click', function(e){ e.stopPropagation(); renderSegment(i); });
1217
+ }
1218
+ resolve(); // let any pending viewer/download callers proceed — composeSegmentCanvas will see no #shotImg and surface its own toast
1219
+ }
1220
+ if (img.complete && img.naturalWidth) paint();
1221
+ else { img.onload = paint; img.onerror = fail; }
1222
  });
1223
  return renderPromise;
1224
  }
 
1234
  const imgs = await Promise.all(SEGMENTS.map(s => loadImage(s.screenshot)));
1235
  const valid = imgs.filter(Boolean);
1236
  if (!valid.length) {
1237
+ showToast('Could not load this session’s screenshots for a full-page export.', true, function(){ exportFullSession(exportMode); });
1238
  return;
1239
  }
1240
  const width = valid[0].naturalWidth;
1241
  let height = 0;
1242
  SEGMENTS.forEach((s, i) => { if (imgs[i]) height = Math.max(height, s.scrollY + imgs[i].naturalHeight); });
1243
  if (!height) {
1244
+ showToast('Could not determine the page size for this export.', true, function(){ exportFullSession(exportMode); });
1245
  return;
1246
  }
1247
 
 
1277
  title: 'Full Session — ' + exportMode.charAt(0).toUpperCase() + exportMode.slice(1) + ' Heatmap',
1278
  downloadName: 'insightux-full-session-' + exportMode + '-heatmap.png',
1279
  });
1280
+ if (!opened) showToast('Could not open the ' + exportMode + ' heatmap viewer.', true, function(){ exportFullSession(exportMode); });
1281
  } catch (err) {
1282
  console.warn('[insightux-report] full-session export failed:', exportMode, err);
1283
+ showToast('Could not build the full-session ' + exportMode + ' heatmap.', true, function(){ exportFullSession(exportMode); });
1284
  } finally {
1285
  if (btn) { btn.innerHTML = original; btn.disabled = false; }
1286
  }
 
1570
  segments = build_screenshot_segments(gaze, dom)
1571
  summary = session_summary(gaze, dom)
1572
  mouse = summarize_mouse(session_dir)
 
1573
  mouse_points = mouse_heat_points(session_dir)
1574
  mouse_segments = build_mouse_screenshot_segments(segments, dom, session_dir)
1575
 
1576
  top_label = ranking[0]["label"] if ranking else "—"
1577
 
1578
+ # session_dir's own folder name is already the session's timestamp
1579
+ # (browser_session.py names it that way) — reused here only to print a
1580
+ # human-readable date in the header, same convention _recent_sessions_json()
1581
+ # already parses on the landing page.
1582
+ try:
1583
+ session_ts = datetime.strptime(os.path.basename(session_dir.rstrip("/\\")), "%Y%m%d_%H%M%S")
1584
+ session_timestamp = session_ts.strftime("%b %d, %Y — %I:%M %p").replace(" 0", " ")
1585
+ except ValueError:
1586
+ session_timestamp = "Unknown time"
1587
+
1588
  html = _TEMPLATE
1589
  html = html.replace("__URL__", summary["url"] or "Unknown page")
1590
+ html = html.replace("__URL_JSON__", json.dumps(summary["url"] or ""))
1591
+ html = html.replace("__SESSION_TIMESTAMP__", session_timestamp)
1592
  html = html.replace("__SAMPLES__", str(summary["samples"]))
1593
  html = html.replace("__DURATION__", str(summary["duration"]))
1594
  html = html.replace("__NUM_ELEMENTS__", str(len(ranking)))
1595
  html = html.replace("__TOP_LABEL__", top_label)
1596
  html = html.replace("__CLICK_COUNT__", str(mouse["click_count"]))
 
1597
  html = html.replace("__RANKING_JSON__", json.dumps(ranking))
1598
  html = html.replace("__TIMELINE_JSON__", json.dumps(timeline))
1599
  html = html.replace("__SEGMENTS_JSON__", json.dumps(segments))
browser_session.py CHANGED
@@ -60,7 +60,7 @@ from preprocessing.preprocessing_pipeline import (
60
  )
61
  from inference_pipeline import InsightUXPipeline, GazeAngleSmoother
62
  from session_logger import GazeLogger
63
- from analysis import generate_report
64
  import theme
65
 
66
 
@@ -182,11 +182,25 @@ __THEME_CSS__
182
  }
183
  .recent-list { display: flex; flex-direction: column; gap: 6px; }
184
  .recent-item {
185
- display: flex; align-items: center; gap: 10px; padding: 10px 14px; cursor: pointer;
186
- color: var(--iux-text-dim); font-size: 12.5px;
187
  }
 
188
  .recent-item:hover { background: var(--iux-surface-hi); }
189
- .recent-item .rt { color: var(--iux-text-faint); margin-left: auto; font-size: 11px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  .recent-empty { color: var(--iux-text-faint); font-size: 12px; text-align: center; padding: 14px 0; }
191
 
192
  .hint {
@@ -258,10 +272,30 @@ __THEME_JS__
258
  if (!RECENT_SESSIONS.length) {
259
  recentList.innerHTML = '<div class="recent-empty">No sessions yet — start tracking on any page to create your first one.</div>';
260
  } else {
261
- recentList.innerHTML = RECENT_SESSIONS.map(function(s){
262
- return '<div class="recent-item">' + iuxIcon('clock', 15) + '<span>' + s.label + '</span>' +
263
- '<span class="rt">' + s.when + '</span></div>';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  }).join('');
 
 
 
 
 
 
265
  }
266
  </script>
267
  </body>
@@ -272,8 +306,18 @@ __THEME_JS__
272
  def _recent_sessions_json(limit=5):
273
  """Read-only directory listing of sessions/<timestamp> folders (already
274
  a well-known constant, SESSIONS_ROOT) for the landing page's "Recent
275
- Sessions" strip. Never touches session contents or tracking state."""
 
 
 
 
 
 
 
 
276
  import datetime as _dt
 
 
277
  entries = []
278
  if os.path.isdir(SESSIONS_ROOT):
279
  for name in os.listdir(SESSIONS_ROOT):
@@ -284,13 +328,36 @@ def _recent_sessions_json(limit=5):
284
  ts = _dt.datetime.strptime(name, "%Y%m%d_%H%M%S")
285
  except ValueError:
286
  continue
287
- entries.append((ts, name))
288
  entries.sort(key=lambda pair: pair[0], reverse=True)
289
- return [
290
- {"label": ts.strftime("%b %d, %Y — %I:%M %p").replace(" 0", " "),
291
- "when": ts.strftime("%Y-%m-%d %H:%M")}
292
- for ts, _name in entries[:limit]
293
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
 
295
 
296
  def _write_landing_page():
@@ -433,6 +500,16 @@ __THEME_CSS__
433
  #__insightux_toolbar .iux-navbtn {
434
  flex:0 0 auto; width:32px; height:32px; border-radius:10px; padding:0;
435
  }
 
 
 
 
 
 
 
 
 
 
436
  #__iux_addr_wrap {
437
  flex:1 1 auto; min-width:0; position:relative; display:flex; align-items:center;
438
  }
@@ -531,6 +608,7 @@ __THEME_CSS__
531
  </div>
532
  <span id="__insightux_update" title="Click to open the download page"></span>
533
  <span id="__insightux_status" class="iux-pill"><span class="txt">Press S to start &middot; H heatmap &middot; M panel &middot; X quit</span></span>
 
534
  <button type="button" class="iux-btn" id="__iux_theme_toggle" title="Toggle theme" style="width:32px;height:32px;"></button>
535
  `;
536
  document.documentElement.appendChild(toolbar);
@@ -585,6 +663,30 @@ __THEME_CSS__
585
  window.insightuxSetPref('sidebarCollapsed', collapsed);
586
  });
587
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
  // -- settings popover --
589
  const settingsBtn = document.getElementById('__iux_settings_btn');
590
  const settingsPop = document.getElementById('__iux_settings_pop');
@@ -625,6 +727,9 @@ __THEME_CSS__
625
  }
626
  document.getElementById('__iux_pill_eye').classList.toggle('on', isTracking);
627
  document.getElementById('__iux_pill_mouse').classList.toggle('on', isTracking);
 
 
 
628
  if (isTracking) {
629
  sessionStartedAt = Date.now();
630
  if (timerInterval) clearInterval(timerInterval);
 
60
  )
61
  from inference_pipeline import InsightUXPipeline, GazeAngleSmoother
62
  from session_logger import GazeLogger
63
+ from analysis import generate_report, load_session, session_summary
64
  import theme
65
 
66
 
 
182
  }
183
  .recent-list { display: flex; flex-direction: column; gap: 6px; }
184
  .recent-item {
185
+ display: flex; align-items: center; gap: 12px; padding: 11px 14px; border-radius: 10px;
186
+ color: var(--iux-text-dim); font-size: 12.5px; transition: background .12s ease;
187
  }
188
+ .recent-item.clickable { cursor: pointer; }
189
  .recent-item:hover { background: var(--iux-surface-hi); }
190
+ .recent-icon { flex-shrink: 0; color: var(--iux-primary-light); display: flex; }
191
+ .recent-info { flex: 1 1 auto; min-width: 0; }
192
+ .recent-label { color: var(--iux-text); font-weight: 600; font-size: 12.5px; }
193
+ .recent-domain {
194
+ color: var(--iux-text-dim); font-size: 11.5px; margin-top: 2px;
195
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
196
+ }
197
+ .recent-stats { color: var(--iux-text-faint); font-size: 11px; margin-top: 2px; }
198
+ .recent-action {
199
+ flex-shrink: 0; display: flex; align-items: center; gap: 5px;
200
+ font-size: 11.5px; font-weight: 600; color: var(--iux-primary-light); white-space: nowrap;
201
+ }
202
+ .recent-item:hover .recent-action { color: var(--iux-lavender); }
203
+ .recent-item .rt { color: var(--iux-text-faint); margin-left: auto; font-size: 11px; flex-shrink: 0; }
204
  .recent-empty { color: var(--iux-text-faint); font-size: 12px; text-align: center; padding: 14px 0; }
205
 
206
  .hint {
 
272
  if (!RECENT_SESSIONS.length) {
273
  recentList.innerHTML = '<div class="recent-empty">No sessions yet — start tracking on any page to create your first one.</div>';
274
  } else {
275
+ recentList.innerHTML = RECENT_SESSIONS.map(function(s, i){
276
+ const stats = [];
277
+ if (s.duration != null) stats.push(s.duration + 's');
278
+ if (s.samples != null) stats.push(s.samples + ' gaze samples');
279
+ const hasReport = !!s.reportUrl;
280
+ return '<div class="recent-item' + (hasReport ? ' clickable' : '') + '" data-idx="' + i + '" title="' +
281
+ (hasReport ? 'Open the report for this session' : 'Report not available for this session') + '">' +
282
+ '<span class="recent-icon">' + iuxIcon('clock', 16) + '</span>' +
283
+ '<div class="recent-info">' +
284
+ '<div class="recent-label">' + s.label + '</div>' +
285
+ (s.domain ? '<div class="recent-domain">' + s.domain + '</div>' : '') +
286
+ (stats.length ? '<div class="recent-stats">' + stats.join(' &middot; ') + '</div>' : '') +
287
+ '</div>' +
288
+ (hasReport
289
+ ? '<span class="recent-action">View Report ' + iuxIcon('arrow-right', 13) + '</span>'
290
+ : '<span class="rt">' + s.when + '</span>') +
291
+ '</div>';
292
  }).join('');
293
+ recentList.querySelectorAll('.recent-item.clickable').forEach(function(el){
294
+ el.addEventListener('click', function(){
295
+ const s = RECENT_SESSIONS[parseInt(el.dataset.idx, 10)];
296
+ if (s && s.reportUrl) location.href = s.reportUrl;
297
+ });
298
+ });
299
  }
300
  </script>
301
  </body>
 
306
  def _recent_sessions_json(limit=5):
307
  """Read-only directory listing of sessions/<timestamp> folders (already
308
  a well-known constant, SESSIONS_ROOT) for the landing page's "Recent
309
+ Sessions" strip. Never touches session contents or tracking state.
310
+
311
+ Each entry is matched to its own already-generated analysis_report.html
312
+ (written by generate_report() at session end, under that same session's
313
+ folder — the existing session-id -> report mapping already used
314
+ elsewhere) so "View Report" always opens THAT session's report, never
315
+ just the most recent one. Duration/sample-count/domain reuse analysis.py's
316
+ existing load_session()/session_summary() — same read-only computation
317
+ the report itself already does, not a new data path."""
318
  import datetime as _dt
319
+ from urllib.parse import urlparse
320
+
321
  entries = []
322
  if os.path.isdir(SESSIONS_ROOT):
323
  for name in os.listdir(SESSIONS_ROOT):
 
328
  ts = _dt.datetime.strptime(name, "%Y%m%d_%H%M%S")
329
  except ValueError:
330
  continue
331
+ entries.append((ts, full))
332
  entries.sort(key=lambda pair: pair[0], reverse=True)
333
+
334
+ out = []
335
+ for ts, full in entries[:limit]:
336
+ report_path = os.path.join(full, "analysis_report.html")
337
+ report_url = None
338
+ if os.path.exists(report_path):
339
+ report_url = "file://" + report_path.replace(os.sep, "/")
340
+
341
+ domain, duration, samples = None, None, None
342
+ try:
343
+ gaze, dom = load_session(full)
344
+ summary = session_summary(gaze, dom)
345
+ duration = summary["duration"] or None
346
+ samples = summary["samples"] or None
347
+ if summary["url"]:
348
+ domain = urlparse(summary["url"]).netloc or summary["url"]
349
+ except Exception:
350
+ pass # malformed/partial session folder — still list it, just without stats
351
+
352
+ out.append({
353
+ "label": ts.strftime("%b %d, %Y — %I:%M %p").replace(" 0", " "),
354
+ "when": ts.strftime("%Y-%m-%d %H:%M"),
355
+ "domain": domain,
356
+ "duration": duration,
357
+ "samples": samples,
358
+ "reportUrl": report_url,
359
+ })
360
+ return out
361
 
362
 
363
  def _write_landing_page():
 
500
  #__insightux_toolbar .iux-navbtn {
501
  flex:0 0 auto; width:32px; height:32px; border-radius:10px; padding:0;
502
  }
503
+ /* Tracking mode: shrink the toolbar down to status pills + a small
504
+ "reveal" handle so the tracked page gets the screen back, without
505
+ ever removing the controls — __iux_reveal_toggle brings them back. */
506
+ #__insightux_toolbar.tracking-compact { height:38px; gap:6px; padding:0 10px; }
507
+ #__insightux_toolbar.tracking-compact .iux-navbtn,
508
+ #__insightux_toolbar.tracking-compact #__iux_addr_wrap,
509
+ #__insightux_toolbar.tracking-compact #__insightux_update,
510
+ #__insightux_toolbar.tracking-compact #__insightux_status { display:none; }
511
+ #__insightux_toolbar.tracking-compact #__iux_reveal_toggle { display:flex !important; }
512
+ body.iux-tracking-compact { padding-top:38px !important; }
513
  #__iux_addr_wrap {
514
  flex:1 1 auto; min-width:0; position:relative; display:flex; align-items:center;
515
  }
 
608
  </div>
609
  <span id="__insightux_update" title="Click to open the download page"></span>
610
  <span id="__insightux_status" class="iux-pill"><span class="txt">Press S to start &middot; H heatmap &middot; M panel &middot; X quit</span></span>
611
+ <button type="button" class="iux-btn" id="__iux_reveal_toggle" title="Show controls" style="width:32px;height:32px;display:none;">${iuxIcon('menu',15)}</button>
612
  <button type="button" class="iux-btn" id="__iux_theme_toggle" title="Toggle theme" style="width:32px;height:32px;"></button>
613
  `;
614
  document.documentElement.appendChild(toolbar);
 
663
  window.insightuxSetPref('sidebarCollapsed', collapsed);
664
  });
665
 
666
+ // -- tracking-mode compact chrome: sidebar + toolbar shrink automatically
667
+ // once a session starts, so the tracked page gets maximum space, without
668
+ // ever removing the controls — __iux_reveal_toggle (only shown while
669
+ // tracking) brings the full toolbar/sidebar back temporarily. --
670
+ const revealToggle = document.getElementById('__iux_reveal_toggle');
671
+ let isTrackingNow = false;
672
+ let controlsRevealed = false;
673
+ function applyTrackingChrome(){
674
+ const compact = isTrackingNow && !controlsRevealed;
675
+ toolbar.classList.toggle('tracking-compact', compact);
676
+ document.body.classList.toggle('iux-tracking-compact', compact);
677
+ if (isTrackingNow) {
678
+ sidebar.classList.toggle('collapsed', !controlsRevealed);
679
+ } else {
680
+ applyCollapsed(window.insightuxGetPrefs().sidebarCollapsed === true);
681
+ }
682
+ revealToggle.innerHTML = iuxIcon(controlsRevealed ? 'x' : 'menu', 15);
683
+ revealToggle.title = controlsRevealed ? 'Hide controls' : 'Show controls';
684
+ }
685
+ revealToggle.addEventListener('click', function(){
686
+ controlsRevealed = !controlsRevealed;
687
+ applyTrackingChrome();
688
+ });
689
+
690
  // -- settings popover --
691
  const settingsBtn = document.getElementById('__iux_settings_btn');
692
  const settingsPop = document.getElementById('__iux_settings_pop');
 
727
  }
728
  document.getElementById('__iux_pill_eye').classList.toggle('on', isTracking);
729
  document.getElementById('__iux_pill_mouse').classList.toggle('on', isTracking);
730
+ isTrackingNow = isTracking;
731
+ controlsRevealed = false;
732
+ applyTrackingChrome();
733
  if (isTracking) {
734
  sessionStartedAt = Date.now();
735
  if (timerInterval) clearInterval(timerInterval);