Aryaman25 commited on
Commit
f9609df
·
1 Parent(s): 32067d6

Update Insight_UX_1.0 with latest changes: add auth, participants, tests, and dev requirements

Browse files
.gitignore CHANGED
@@ -16,6 +16,7 @@
16
  !.gitattributes
17
  !README.md
18
  !requirements.txt
 
19
  !version.json
20
 
21
  !calibrate.py
@@ -25,6 +26,13 @@
25
  !inference_pipeline.py
26
  !session_logger.py
27
  !theme.py
 
 
 
 
 
 
 
28
 
29
  !packaging/InsightUX.spec
30
  !packaging/installer.iss
@@ -47,10 +55,15 @@ sessions/ # full-screen screenshots of real browsing — NEVER push
47
  calibration.pkl # your personal eye + screen calibration
48
  baseline_pose.pkl
49
  insightux_landing.html # regenerated at runtime
 
 
 
 
50
  venv/
51
  test_images/
52
  test.mp4
53
  __pycache__/
54
  *.py[cod]
 
55
  .DS_Store
56
  Thumbs.db
 
16
  !.gitattributes
17
  !README.md
18
  !requirements.txt
19
+ !requirements-dev.txt
20
  !version.json
21
 
22
  !calibrate.py
 
26
  !inference_pipeline.py
27
  !session_logger.py
28
  !theme.py
29
+ !auth.py
30
+ !participants.py
31
+
32
+ !tests/conftest.py
33
+ !tests/test_auth.py
34
+ !tests/test_participants.py
35
+ !tests/test_analysis.py
36
 
37
  !packaging/InsightUX.spec
38
  !packaging/installer.iss
 
55
  calibration.pkl # your personal eye + screen calibration
56
  baseline_pose.pkl
57
  insightux_landing.html # regenerated at runtime
58
+ insightux_login.html # regenerated at runtime
59
+ users.json # profile records — password hashes, never push
60
+ users/ # per-profile sessions/calibration/fine-tuned models
61
+ _no_active_profile/ # inert placeholder dir touched only while logged out
62
  venv/
63
  test_images/
64
  test.mp4
65
  __pycache__/
66
  *.py[cod]
67
+ .pytest_cache/
68
  .DS_Store
69
  Thumbs.db
analysis.py CHANGED
@@ -19,6 +19,7 @@ import re
19
  import json
20
  import bisect
21
  import base64
 
22
  from datetime import datetime
23
 
24
  import theme
@@ -299,12 +300,13 @@ def build_screenshot_segments(gaze, dom, session_dir):
299
  continue
300
  viewport = ev.get("viewport") or {}
301
  segments.append({
302
- "screenshot": ev["screenshot"], # relative path — kept only as the join key for build_mouse_screenshot_segments
303
  "screenshotData": data_uri, # what the report's <img>/canvas actually load
304
  "scrollY": ev.get("scrollY", 0),
305
  "viewportW": viewport.get("w"),
306
  "viewportH": viewport.get("h"),
307
  "points": pts,
 
308
  "duration": 0.0, # filled below
309
  })
310
 
@@ -319,56 +321,39 @@ def build_screenshot_segments(gaze, dom, session_dir):
319
 
320
 
321
  # =============================================================================
322
- # MOUSE HEATMAP DATA (presentation-layer addition only every point below
323
- # was already written to mouse_log.jsonl by the existing MOUSE_JS sampling
324
- # loop and Api.log_mouse_data(); summarize_mouse() above is unchanged and
325
- # still powers the interests table / click log / click-timeline exactly as
326
- # before. This function just also surfaces the raw coordinates, bucketed
327
- # per screenshot, so the report's Mouse/Combined heatmap tabs and the
328
- # full-session export have something to draw — no new tracking, no new
329
- # collection. The full-session export reconstructs its whole-page point set
330
- # from these same per-segment buckets (scrollY-offset per segment) rather
331
- # than from a flat whole-session list, so it automatically inherits the
332
- # per-segment url/scrollY grouping and never mixes points from a different
333
- # page navigation into the same stitched image.)
334
  # =============================================================================
335
 
336
- def build_mouse_screenshot_segments(segments, dom, session_dir):
337
- """Mouse-tracker counterpart to build_screenshot_segments (which is left
338
- untouched) buckets the same already-recorded mouse coordinates against
339
- the exact same screenshots that function kept for the gaze heatmap, so
340
- the Eye/Mouse/Combined tabs share one scroll-position index. Batches are
341
- matched to the nearest screenshot by their own "t" (already written by
342
- log_mouse_data), then each point is converted from page-absolute to
343
- screen-relative (page_y - scrollY), matching how gaze points are already
344
- stored in `segments`."""
345
- if not segments:
346
- return []
347
- kept_names = {s["screenshot"] for s in segments}
348
- by_name = {d["screenshot"]: d for d in dom if d.get("screenshot") in kept_names}
349
- ordered = [by_name[s["screenshot"]] for s in segments if s["screenshot"] in by_name]
350
- if len(ordered) != len(segments):
351
- return [{"points": []} for _ in segments]
352
-
353
- shot_times = [d["t"] for d in ordered]
354
- buckets = [[] for _ in ordered]
355
-
356
  for b in load_mouse_batches(session_dir):
357
- t = b.get("t")
358
- if t is None:
359
- continue
360
- idx = bisect.bisect_right(shot_times, t) - 1
361
- if idx < 0:
362
- idx = 0
363
- scroll_y = ordered[idx].get("scrollY", 0)
364
  for p in (b.get("trail") or []):
365
- buckets[idx].append({"sx": round(p["x"], 1), "sy": round(p["y"] - scroll_y, 1)})
366
  for p in (b.get("heatmap") or []):
367
- buckets[idx].append({"sx": round(p["x"], 1), "sy": round(p["y"] - scroll_y, 1)})
368
  for c in (b.get("click") or []):
369
- buckets[idx].append({"sx": round(c["x"], 1), "sy": round(c["y"] - scroll_y, 1), "w": 5})
 
370
 
371
- return [{"points": pts} for pts in buckets]
 
 
 
 
 
 
 
372
 
373
 
374
  # =============================================================================
@@ -392,7 +377,7 @@ __THEME_CSS__
392
  .topbar .brand { display: flex; align-items: center; gap: 10px; }
393
  .topbar .mark {
394
  width: 34px; height: 34px; border-radius: 10px; background: var(--iux-accent-grad);
395
- display: flex; align-items: center; justify-content: center; color: #fff; flex-shrink: 0;
396
  }
397
  h1 { margin: 0; font-size: 19px; font-weight: 700; }
398
  .sub { color: var(--iux-text-dim); font-size: 12.5px; margin-top: 2px; }
@@ -405,7 +390,7 @@ __THEME_CSS__
405
  }
406
  .stat .icon-badge {
407
  width: 30px; height: 30px; border-radius: 9px; display: flex; align-items: center; justify-content: center;
408
- background: var(--iux-accent-grad); color: #fff; margin-bottom: 10px;
409
  }
410
  .stat .v { font-size: 21px; font-weight: 700; color: var(--iux-text); }
411
  .stat .l { font-size: 10.5px; color: var(--iux-text-faint); text-transform: uppercase; letter-spacing: .05em; margin-top: 2px; }
@@ -453,7 +438,7 @@ __THEME_CSS__
453
  border: none; background: transparent; color: var(--iux-text-dim); padding: 6px 12px; border-radius: 999px;
454
  font-size: 11.5px; cursor: pointer; font-family: var(--iux-font);
455
  }
456
- .hm-toolbar .seg button.on { background: var(--iux-accent-grad); color: #fff; }
457
  .hm-toolbar .slider-mini { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--iux-text-faint); }
458
  .hm-toolbar .slider-mini input { accent-color: var(--iux-primary-light); }
459
  .hm-toolbar .iux-btn { padding: 7px 10px; font-size: 11.5px; margin-left: auto; }
@@ -462,10 +447,7 @@ __THEME_CSS__
462
  .shot-wrap:hover { box-shadow: var(--iux-shadow-glow); }
463
  .shot-wrap img, .shot-wrap canvas { display: block; width: 100%; height: auto; }
464
  .shot-wrap canvas { position: absolute; top: 0; left: 0; }
465
- .shot-nav { display: flex; align-items: center; justify-content: space-between;
466
- margin-top: 10px; font-size: 12px; color: var(--iux-text-dim); }
467
- .shot-nav .iux-btn { padding: 7px 14px; font-size: 12px; }
468
- .shot-nav .iux-btn:disabled { opacity: 0.35; cursor: default; transform:none; }
469
 
470
  .click-timeline { width: 100%; height: 60px; display: block; margin-top: 8px; }
471
 
@@ -475,7 +457,7 @@ __THEME_CSS__
475
  .elem-chart-heading { display: flex; align-items: center; gap: 12px; }
476
  .elem-chart-heading .icon-badge {
477
  width: 34px; height: 34px; border-radius: 10px; display: flex; align-items: center; justify-content: center;
478
- background: var(--iux-accent-grad); color: #fff; flex-shrink: 0;
479
  }
480
  .elem-chart-heading h2 {
481
  margin: 0; font-size: 12.5px; color: var(--iux-text-dim); font-weight: 700;
@@ -498,7 +480,7 @@ __THEME_CSS__
498
  cursor: pointer; text-align: left;
499
  }
500
  .elem-view-opt:hover { background: var(--iux-surface); color: var(--iux-text); }
501
- .elem-view-opt.on { background: var(--iux-accent-grad); color: #fff; }
502
 
503
  .elem-chart-wrap { position: relative; }
504
  .elem-chart-scroll {
@@ -529,13 +511,13 @@ __THEME_CSS__
529
  .elem-fullscreen.open { display: flex; }
530
  .elem-fullscreen-bar {
531
  display: flex; align-items: center; gap: 14px; padding: 14px 20px; flex-wrap: wrap;
532
- background: rgba(18,19,26,0.9); border-bottom: 1px solid rgba(255,255,255,0.1); flex-shrink: 0;
533
  }
534
- .elem-fullscreen-title { color: #f1eefa; font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 8px; margin-right: auto; }
535
  .elem-fullscreen-controls-slot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
536
- .elem-fullscreen-bar .iux-btn { color: #f1eefa; background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.14); }
537
  .elem-fullscreen-bar .iux-btn:hover { background: rgba(255,255,255,0.18); }
538
- .elem-fullscreen-bar .elem-view-menu { background: #201f2e; }
539
  .elem-fullscreen-body { flex: 1 1 auto; padding: 24px; overflow: auto; }
540
  .elem-fullscreen-body .elem-summary-row { max-width: 900px; }
541
 
@@ -543,34 +525,34 @@ __THEME_CSS__
543
 
544
  .iux-viewer {
545
  position: fixed; inset: 0; z-index: 2000000; display: none; flex-direction: column;
546
- background: rgba(10,8,20,0.92); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
547
  }
548
  .iux-viewer.open { display: flex; }
549
  .iv-topbar {
550
  display: flex; align-items: center; gap: 10px; padding: 12px 18px;
551
- background: rgba(18,19,26,0.9); border-bottom: 1px solid rgba(255,255,255,0.1); flex-shrink: 0;
552
  }
553
- .iv-topbar .iux-btn { color: #f1eefa; background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.14); }
554
  .iv-topbar .iux-btn:hover { background: rgba(255,255,255,0.18); }
555
- .iv-title { color: #f1eefa; font-size: 13px; font-weight: 600; margin-right: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
556
- .iv-zoom-pct { color: #cabdf0; font-size: 12px; min-width: 44px; text-align: center; flex-shrink: 0; }
557
  .iv-stage { flex: 1 1 auto; position: relative; overflow: hidden; cursor: grab; }
558
  .iv-stage.grabbing { cursor: grabbing; }
559
  #ivImage { position: absolute; top: 50%; left: 50%; max-width: none; user-select: none; -webkit-user-select: none; }
560
  .iv-nav {
561
  position: absolute; top: 50%; transform: translateY(-50%); z-index: 5;
562
  width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
563
- color: #f1eefa; background: rgba(18,19,26,0.65); border-color: rgba(255,255,255,0.16);
564
  }
565
- .iv-nav:hover { background: rgba(139,92,246,0.55); }
566
  .iv-nav-prev { left: 16px; }
567
  .iv-nav-next { right: 16px; }
568
  .iv-nav[disabled] { display: none; }
569
  .iv-bottombar {
570
  display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px 18px;
571
- background: rgba(18,19,26,0.9); border-top: 1px solid rgba(255,255,255,0.1); flex-shrink: 0; flex-wrap: wrap;
572
  }
573
- .iv-bottombar .iux-btn { color: #f1eefa; background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.14); padding: 7px 12px; font-size: 12px; }
574
  .iv-bottombar .iux-btn:hover { background: rgba(255,255,255,0.18); }
575
  .iv-bottombar input[type=range] { accent-color: var(--iux-primary-light); width: 120px; }
576
 
@@ -591,7 +573,7 @@ __THEME_CSS__
591
  }
592
  @page { size: A4; margin: 0; }
593
  @media print {
594
- html, body { background: #12131A !important; }
595
  body > .back-nav, body > .topbar, body > .stat-row, body > .grid,
596
  #iuxViewer, #elemFullscreen, #iuxReportToast { display: none !important; }
597
  #printReport { position: static !important; left: auto !important; width: auto !important; }
@@ -599,7 +581,7 @@ __THEME_CSS__
599
 
600
  .print-page {
601
  position: relative; width: 794px; height: 1123px;
602
- background: #12131A; color: #F1EEFA;
603
  box-sizing: border-box; overflow: hidden;
604
  break-after: page; page-break-after: always;
605
  font-family: var(--iux-font);
@@ -607,71 +589,71 @@ __THEME_CSS__
607
  .print-page:last-child { break-after: auto; page-break-after: auto; }
608
 
609
  .pp-accent { position: absolute; top: 0; left: 0; right: 0; height: 6px;
610
- background: linear-gradient(90deg, #6366F1, #8B5CF6 55%, #C084FC); }
611
  .pp-header { position: absolute; top: 16px; left: 46px; right: 46px; height: 20px;
612
  display: flex; align-items: center; justify-content: space-between; }
613
- .pp-header .pp-brand { font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; color: #F1EEFA; }
614
- .pp-header .pp-tag { font-size: 9px; font-weight: 600; letter-spacing: 0.12em; color: #6E6885; text-transform: uppercase; }
615
  .pp-body { position: absolute; top: 46px; left: 46px; right: 46px; bottom: 52px; overflow: hidden; }
616
  .pp-footer { position: absolute; bottom: 0; left: 46px; right: 46px; height: 40px;
617
  display: flex; align-items: center; justify-content: space-between;
618
- border-top: 1px solid rgba(148,138,179,0.16); font-size: 8.5px; color: #6E6885; }
619
 
620
- .pp-cover-title { font-size: 25px; font-weight: 700; color: #F1EEFA; margin: 8px 0 4px; }
621
- .pp-cover-meta { font-size: 10.5px; color: #9B95B3; margin-bottom: 20px; }
622
 
623
  .pp-metric-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 18px; }
624
- .pp-metric { background: #1E2030; border: 1px solid rgba(148,138,179,0.16); border-radius: 12px; padding: 12px 14px; break-inside: avoid; }
625
- .pp-metric .pp-m-label { font-size: 8.5px; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: #6E6885; margin-bottom: 6px; }
626
- .pp-metric .pp-m-value { font-size: 19px; font-weight: 700; color: #F1EEFA; }
627
- .pp-metric .pp-m-sub { font-size: 8.5px; color: #6E6885; margin-top: 2px; }
628
-
629
- .pp-card { background: #1E2030; border: 1px solid rgba(148,138,179,0.16); border-radius: 12px; padding: 16px 18px; margin-bottom: 14px; break-inside: avoid; }
630
- .pp-section-title { font-size: 11.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: #F1EEFA; margin: 0 0 10px; }
631
- .pp-section-sub { font-size: 10px; color: #9B95B3; margin: -6px 0 12px; line-height: 1.5; }
632
- .pp-body-text { font-size: 10.5px; color: #B7B2CC; line-height: 1.65; }
633
- .pp-body-text b { color: #F1EEFA; }
634
  .pp-two-col { display: grid; grid-template-columns: 1.35fr 1fr; gap: 14px; }
635
 
636
  .pp-table { width: 100%; border-collapse: collapse; font-size: 10px; }
637
- .pp-table th { text-align: left; color: #6E6885; font-weight: 700; padding: 6px 8px; font-size: 8.5px;
638
- text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid rgba(148,138,179,0.25); }
639
- .pp-table td { padding: 7px 8px; border-bottom: 1px solid rgba(148,138,179,0.12); vertical-align: middle; }
640
- .pp-table tr:nth-child(even) td { background: rgba(148,138,179,0.05); }
641
  .pp-table .pp-num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
642
- .pp-table .pp-rank { color: #6E6885; width: 22px; }
643
  .pp-table .pp-el-name { word-break: break-word; }
644
 
645
  .pp-snap-item { margin-bottom: 12px; }
646
- .pp-snap-item .pp-snap-label { font-size: 8.5px; text-transform: uppercase; letter-spacing: 0.05em; color: #6E6885; margin-bottom: 3px; }
647
- .pp-snap-item .pp-snap-value { font-size: 13px; font-weight: 700; color: #F1EEFA; }
648
 
649
- .pp-shot-frame { width: 100%; border-radius: 10px; overflow: hidden; border: 1px solid rgba(148,138,179,0.2); background: #171923; }
650
  .pp-shot-frame img { display: block; width: 100%; height: auto; }
651
- .pp-shot-label { font-size: 9.5px; color: #9B95B3; margin-bottom: 8px; display: flex; justify-content: space-between; }
652
  .pp-shot-row { display: grid; gap: 12px; margin-bottom: 14px; break-inside: avoid; }
653
  .pp-shot-row.pp-cols-1 { grid-template-columns: 1fr; }
654
  .pp-shot-row.pp-cols-2 { grid-template-columns: 1fr 1fr; }
655
 
656
- .pp-chart-caption { font-size: 9px; color: #6E6885; margin-top: 8px; text-align: center; }
657
 
658
  .pp-detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
659
- .pp-detail-card { background: #171923; border: 1px solid rgba(148,138,179,0.14); border-radius: 10px; padding: 10px 12px; break-inside: avoid; }
660
- .pp-detail-card .pp-d-name { font-size: 10.5px; font-weight: 600; color: #F1EEFA; margin-bottom: 6px; }
661
- .pp-detail-card .pp-d-row { display: flex; justify-content: space-between; font-size: 9px; color: #9B95B3; padding: 2px 0; }
662
- .pp-detail-card .pp-d-row b { color: #F1EEFA; font-weight: 600; }
663
 
664
- .pp-click-item { border-bottom: 1px solid rgba(148,138,179,0.12); padding: 7px 0; font-size: 10px; break-inside: avoid; }
665
- .pp-click-item .pp-click-time { font-size: 8.5px; color: #6E6885; }
666
- .pp-click-item .pp-click-el { font-weight: 600; color: #F1EEFA; }
667
- .pp-click-item .pp-click-text { color: #9B95B3; font-size: 9.5px; }
668
 
669
  .pp-insight-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
670
- .pp-insight-card { background: #1E2030; border: 1px solid rgba(148,138,179,0.16); border-radius: 12px; padding: 16px; break-inside: avoid; }
671
- .pp-insight-card .pp-i-label { font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; color: #6E6885; margin-bottom: 6px; }
672
- .pp-insight-card .pp-i-value { font-size: 17px; font-weight: 700; color: #F1EEFA; }
673
 
674
- .pp-empty { font-size: 10.5px; color: #6E6885; padding: 10px 0; }
675
  </style>
676
  </head>
677
  <body>
@@ -683,7 +665,7 @@ __THEME_CSS__
683
  <div class="mark">__EYE_ICON__</div>
684
  <div>
685
  <h1>InsightUX Session Report</h1>
686
- <div class="sub">__URL__ &nbsp;&middot;&nbsp; __SESSION_TIMESTAMP__ &nbsp;&middot;&nbsp; __DURATION__s &nbsp;&middot;&nbsp; __SAMPLES__ gaze samples</div>
687
  </div>
688
  </div>
689
  <div class="actions">
@@ -717,10 +699,10 @@ __THEME_CSS__
717
  <div class="legend-scale">
718
  <span>Little</span><div class="bar"></div><span>A lot</span>
719
  </div>
720
- <p>Each card is a different scroll position the report splits
721
- the page automatically whenever you scrolled far enough that the view
722
- changed meaningfully. Use the full-session export buttons to download
723
- one stitched image covering the entire page.</p>
724
  </div>
725
  </div>
726
  <div class="panel iux-card full iux-fade-in">
@@ -830,7 +812,8 @@ const RANKING = __RANKING_JSON__;
830
  const SEGMENTS = __SEGMENTS_JSON__;
831
  const MOUSE_INTERESTS = __MOUSE_INTERESTS_JSON__;
832
  const MOUSE_CLICKS = __MOUSE_CLICKS_JSON__;
833
- const MOUSE_SEGMENTS = __MOUSE_SEGMENTS_JSON__;
 
834
  const MAX_ATTENTION_ITEMS = __MAX_ATTENTION_ITEMS__;
835
  const SESSION = {
836
  url: __URL_JSON__,
@@ -1071,13 +1054,11 @@ function iconForLabel(label){
1071
  if (MOUSE_CLICKS.length > 1) {
1072
  const times = MOUSE_CLICKS.map(c => { const p = (c.timestamp||'').split(':').map(Number); return (p[0]||0)*3600+(p[1]||0)*60+(p[2]||0); });
1073
  const tmin = Math.min(...times), tmax = Math.max(...times) || tmin + 1;
1074
- ctx.strokeStyle = 'rgba(139,92,246,0.25)';
1075
  ctx.beginPath(); ctx.moveTo(0, cv.height - 10); ctx.lineTo(cv.width, cv.height - 10); ctx.stroke();
1076
  times.forEach(function(t){
1077
  const x = ((t - tmin) / (tmax - tmin || 1)) * (cv.width - 12) + 6;
1078
- const grad = ctx.createLinearGradient(x, 6, x, cv.height - 10);
1079
- grad.addColorStop(0, '#C084FC'); grad.addColorStop(1, '#6366F1');
1080
- ctx.fillStyle = grad;
1081
  ctx.beginPath(); ctx.arc(x, cv.height - 10, 4, 0, 2*Math.PI); ctx.fill();
1082
  });
1083
  }
@@ -1138,9 +1119,9 @@ const EYE_STOPS = [
1138
  [1.00, 255, 45, 0],
1139
  ];
1140
  const MOUSE_STOPS = [
1141
- [0.00, 99, 102, 241],
1142
- [0.35, 103, 232, 249],
1143
- [0.70, 192, 132, 252],
1144
  [1.00, 255, 255, 255],
1145
  ];
1146
  const HEAT_RADIUS = 22; // was 55/60 — smaller, more precise fixation spots
@@ -1218,7 +1199,102 @@ function showToast(message, isError, onRetry){
1218
  toast._hideTimer = setTimeout(function(){ toast.style.display = 'none'; }, onRetry ? 6000 : 3200);
1219
  }
1220
 
1221
- // ---------- Screenshot-backed heatmaps: Eye / Mouse / Combined tabs, paginated ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1222
  (function(){
1223
  const area = document.getElementById('heatmapArea');
1224
  const toolbar = document.getElementById('hmToolbar');
@@ -1229,7 +1305,6 @@ function showToast(message, isError, onRetry){
1229
  }
1230
  toolbar.style.display = 'flex';
1231
 
1232
- let cur = 0;
1233
  let mode = 'eye'; // 'eye' | 'mouse' | 'combined'
1234
  const heatState = {
1235
  eye: { opacity: 0.9, intensity: 0.09 },
@@ -1237,39 +1312,37 @@ function showToast(message, isError, onRetry){
1237
  combined: { opacity: 0.9, intensity: 0.10 },
1238
  };
1239
 
1240
- // Mouse/DOM coordinates (scrollY, click/trail x-y) are recorded in CSS
1241
- // pixels by the tracked page itself; gaze sx/sy and the screenshots are
1242
- // physical screen pixels (pyautogui.screenshot()). Those only match 1:1
1243
- // at 100% OS display scale — dprFor() recovers the real ratio from the
1244
- // screenshot's actual decoded size vs. the CSS viewport width recorded
1245
- // alongside it, so mouse points land on the spot they were actually at
1246
- // instead of being compressed toward the top-left on scaled displays.
1247
- let curDpr = 1;
1248
- function dprFromViewport(vw, img){
1249
- if (!vw || !img || !img.naturalWidth) return 1;
1250
- const d = img.naturalWidth / vw;
1251
- return (isFinite(d) && d > 0) ? d : 1;
1252
- }
1253
- function dprFor(i, img){
1254
- return dprFromViewport((SEGMENTS[i] || {}).viewportW, img);
1255
- }
1256
- function pointsFor(kind, i){
1257
- if (kind === 'eye') return (SEGMENTS[i] || {}).points || [];
1258
- const pts = (MOUSE_SEGMENTS[i] || {}).points || [];
1259
- if (curDpr === 1) return pts;
1260
- return pts.map(function(p){ return { sx: p.sx * curDpr, sy: p.sy * curDpr, w: p.w }; });
1261
- }
1262
-
1263
- function paintActive(ctx, w, h){
1264
- ctx.clearRect(0, 0, w, h);
1265
  const st = heatState[mode];
1266
  if (mode === 'eye'){
1267
- paintHeatLayer(ctx, w, h, pointsFor('eye', cur), st.intensity, EYE_STOPS, st.opacity);
1268
  } else if (mode === 'mouse'){
1269
- paintHeatLayer(ctx, w, h, pointsFor('mouse', cur), st.intensity, MOUSE_STOPS, st.opacity);
1270
  } else {
1271
- paintHeatLayer(ctx, w, h, pointsFor('eye', cur), st.intensity, EYE_STOPS, st.opacity);
1272
- paintHeatLayer(ctx, w, h, pointsFor('mouse', cur), st.intensity, MOUSE_STOPS, st.opacity, 'lighter');
1273
  }
1274
  }
1275
 
@@ -1279,149 +1352,109 @@ function showToast(message, isError, onRetry){
1279
  document.querySelectorAll('.hm-tab').forEach(b => b.classList.toggle('on', b === btn));
1280
  document.getElementById('hmOpacity').value = Math.round(heatState[mode].opacity * 100);
1281
  document.getElementById('hmIntensity').value = Math.round(heatState[mode].intensity * 33);
1282
- renderSegment(cur);
1283
  });
1284
  });
1285
  document.getElementById('hmOpacity').addEventListener('input', function(e){
1286
  heatState[mode].opacity = e.target.value / 100;
1287
- renderSegment(cur);
1288
  });
1289
  document.getElementById('hmIntensity').addEventListener('input', function(e){
1290
  heatState[mode].intensity = e.target.value / 33;
1291
- renderSegment(cur);
1292
  });
1293
- // Flattens the visible screenshot + its heat overlay into ONE opaque
 
1294
  // canvas. The on-screen view layers a transparent <canvas> on top of an
1295
  // <img> purely via CSS positioning — exporting the heat canvas alone (as
1296
  // earlier code did) produces a mostly-transparent PNG that most viewers
1297
  // render as solid black. This is the single source both the fullscreen
1298
  // viewer and the direct download button use, so both always show the
1299
  // real composited picture.
1300
- function composeSegmentCanvas(){
1301
- const shotImg = document.getElementById('shotImg');
1302
- const heatCv = document.getElementById('shotCanvas');
1303
- if (!shotImg || !heatCv || !shotImg.complete || !shotImg.naturalWidth) return null;
1304
  const out = document.createElement('canvas');
1305
- out.width = shotImg.naturalWidth;
1306
- out.height = shotImg.naturalHeight;
1307
  const octx = out.getContext('2d');
1308
  octx.fillStyle = '#ffffff';
1309
  octx.fillRect(0, 0, out.width, out.height);
1310
- octx.drawImage(shotImg, 0, 0);
1311
- octx.drawImage(heatCv, 0, 0);
1312
  return out;
1313
  }
1314
 
1315
- function segmentTitle(i){
1316
- return 'Scroll segment ' + (i+1) + ' of ' + SEGMENTS.length + ' — ' + mode.charAt(0).toUpperCase() + mode.slice(1) + ' heatmap';
1317
  }
1318
- function segmentDownloadName(i){
1319
- return 'insightux-' + mode + '-heatmap-segment-' + (i+1) + '.png';
1320
  }
1321
 
1322
- // Opens (or, if already open, updates in place) the shared viewer for
1323
- // segment i — used by the maximize button, clicking the screenshot
1324
- // itself, and the viewer's own Previous/Next controls.
1325
- function openSegmentViewer(i){
1326
- if (i < 0 || i >= SEGMENTS.length) return;
1327
- if (i !== cur) { cur = i; renderSegment(cur); }
1328
- renderPromise.then(function(){
1329
- const canvas = composeSegmentCanvas();
1330
- if (!canvas) {
1331
- showToast('Could not prepare this screenshot for viewing.', true, function(){ renderSegment(i); openSegmentViewer(i); });
1332
- return;
1333
- }
1334
- Viewer.open({
1335
- source: canvas,
1336
- title: segmentTitle(i),
1337
- downloadName: segmentDownloadName(i),
1338
- onPrev: i > 0 ? function(){ openSegmentViewer(i - 1); } : null,
1339
- onNext: i < SEGMENTS.length - 1 ? function(){ openSegmentViewer(i + 1); } : null,
1340
- });
1341
  });
1342
  }
1343
 
1344
- document.getElementById('hmFullscreen').addEventListener('click', function(){ openSegmentViewer(cur); });
1345
  document.getElementById('hmDownload').addEventListener('click', function(){
1346
- const theCur = cur;
1347
- const canvas = composeSegmentCanvas();
1348
  if (!canvas) {
1349
- showToast('Could not prepare this screenshot for export.', true, function(){ renderSegment(theCur); });
1350
  return;
1351
  }
1352
  try {
1353
  const a = document.createElement('a');
1354
  a.href = canvas.toDataURL('image/png');
1355
- a.download = segmentDownloadName(cur);
1356
  a.click();
1357
  } catch (err) {
1358
- console.warn('[insightux-report] segment download failed:', err);
1359
  showToast('Could not export this image.', true);
1360
  }
1361
  });
1362
 
1363
- let renderPromise = Promise.resolve();
1364
-
1365
- // Renders segment i's screenshot + active heat layer(s). `shotImg`'s src
1366
- // is the data: URI from segment.screenshotData (embedded at report-build
1367
- // time see _screenshot_data_uri() in analysis.py) rather than a
1368
- // file:// path, so later reading the composited canvas back out
1369
- // (composeSegmentCanvas, for the viewer/download) doesn't hit the
1370
- // packaged app's file:// canvas-tainting restriction. Returns a promise
1371
- // that resolves once the image has actually painted, so callers that
1372
- // need the pixels (the viewer, download) can wait for it instead of
1373
- // racing it.
1374
- function renderSegment(i){
1375
- const seg = SEGMENTS[i];
1376
- area.innerHTML = `
1377
- <div class="shot-wrap" id="shotWrap" title="Click to open in viewer">
1378
- <img id="shotImg" src="${seg.screenshotData}" alt="">
1379
- <canvas id="shotCanvas"></canvas>
1380
- </div>
1381
- <div class="shot-nav">
1382
- <button class="iux-btn" id="prevBtn" ${i===0?'disabled':''}>${iuxIcon('chevron-left',13)} Earlier</button>
1383
- <span>Scroll segment ${i+1} of ${SEGMENTS.length} &middot; ~${seg.duration}s of attention here</span>
1384
- <button class="iux-btn" id="nextBtn" ${i===SEGMENTS.length-1?'disabled':''}>Later ${iuxIcon('chevron-right',13)}</button>
1385
- </div>
1386
- `;
1387
-
1388
- document.getElementById('prevBtn').onclick = () => { if (cur>0){ cur--; renderSegment(cur); } };
1389
- document.getElementById('nextBtn').onclick = () => { if (cur<SEGMENTS.length-1){ cur++; renderSegment(cur); } };
1390
- document.getElementById('shotWrap').addEventListener('click', function(){ openSegmentViewer(i); });
1391
-
1392
- const img = document.getElementById('shotImg');
1393
- const cv = document.getElementById('shotCanvas');
1394
- const ctx = cv.getContext('2d');
1395
-
1396
- renderPromise = new Promise(function(resolve){
1397
- function paint(){
1398
- cv.width = img.naturalWidth;
1399
- cv.height = img.naturalHeight;
1400
- curDpr = dprFor(i, img);
1401
- paintActive(ctx, cv.width, cv.height);
1402
- resolve();
1403
- }
1404
- function fail(){
1405
- const shotWrap = document.getElementById('shotWrap');
1406
- if (shotWrap) {
1407
- shotWrap.innerHTML =
1408
- '<div class="empty"><span class="ei">' + iuxIcon('info', 26) + '</span>' +
1409
- 'Could not load the screenshot for segment ' + (i+1) + '.<br>' +
1410
- '<button type="button" class="iux-btn" id="retryShotBtn" style="margin-top:10px;">' +
1411
- iuxIcon('refresh', 13) + ' Retry</button></div>';
1412
- shotWrap.removeAttribute('title');
1413
- const retryBtn = document.getElementById('retryShotBtn');
1414
- if (retryBtn) retryBtn.addEventListener('click', function(e){ e.stopPropagation(); renderSegment(i); });
1415
- }
1416
- resolve(); // let any pending viewer/download callers proceed — composeSegmentCanvas will see no #shotImg and surface its own toast
1417
  }
1418
- if (img.complete && img.naturalWidth) paint();
1419
- else { img.onload = paint; img.onerror = fail; }
1420
- });
1421
- return renderPromise;
1422
  }
1423
 
1424
- renderSegment(cur);
1425
  })();
1426
 
1427
  // ---------- Element Attention Analysis: plots EVERY row of the already-
@@ -1465,6 +1498,12 @@ function showToast(message, isError, onRetry){
1465
  let hoverIdx = -1;
1466
  let animProgress = 0;
1467
  let raf = null;
 
 
 
 
 
 
1468
 
1469
  const MIN_SLOT = 60; // generous px per element — chart scrolls instead of compressing
1470
 
@@ -1526,8 +1565,8 @@ function showToast(message, isError, onRetry){
1526
  ctx.lineTo(pts[pts.length - 1].x, g.padTop + g.plotH);
1527
  ctx.closePath();
1528
  const areaGrad = ctx.createLinearGradient(0, g.padTop, 0, g.padTop + g.plotH);
1529
- areaGrad.addColorStop(0, 'rgba(139,92,246,0.30)');
1530
- areaGrad.addColorStop(1, 'rgba(139,92,246,0)');
1531
  ctx.fillStyle = areaGrad;
1532
  ctx.fill();
1533
  }
@@ -1540,7 +1579,7 @@ function showToast(message, isError, onRetry){
1540
  ctx.quadraticCurveTo(cur.x, cur.y, midX, midY);
1541
  }
1542
  ctx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y);
1543
- ctx.strokeStyle = '#8B5CF6';
1544
  ctx.lineWidth = 2.5;
1545
  ctx.lineJoin = 'round';
1546
  ctx.lineCap = 'round';
@@ -1556,8 +1595,8 @@ function showToast(message, isError, onRetry){
1556
  const h = bottom - top;
1557
  if (h <= 0) return;
1558
  const grad = ctx.createLinearGradient(0, top, 0, bottom);
1559
- grad.addColorStop(0, '#C084FC');
1560
- grad.addColorStop(1, '#6366F1');
1561
  ctx.fillStyle = grad;
1562
  const r = Math.min(6, barW / 2, h);
1563
  ctx.beginPath();
@@ -1573,11 +1612,13 @@ function showToast(message, isError, onRetry){
1573
  }
1574
 
1575
  function paint(){
1576
- const dim = getComputedStyle(document.documentElement).getPropertyValue('--iux-text-dim').trim() || '#9a95b3';
1577
- const faint = getComputedStyle(document.documentElement).getPropertyValue('--iux-text-faint').trim() || '#6e6885';
1578
- const border = getComputedStyle(document.documentElement).getPropertyValue('--iux-border').trim() || 'rgba(148,138,179,0.16)';
1579
- const bgAlt = getComputedStyle(document.documentElement).getPropertyValue('--iux-bg-alt').trim() || '#171923';
1580
- const strong = document.documentElement.getAttribute('data-theme') === 'light' ? '#211D33' : '#F1EEFA';
 
 
1581
  const g = geometry();
1582
 
1583
  // Opaque fill first: this canvas is also what Export reads directly,
@@ -1613,7 +1654,7 @@ function showToast(message, isError, onRetry){
1613
  const isHover = i === hoverIdx;
1614
  ctx.beginPath();
1615
  ctx.arc(pt.x, pt.y, isHover ? 6.5 : 4, 0, 2 * Math.PI);
1616
- ctx.fillStyle = isHover ? '#C084FC' : '#8B5CF6';
1617
  ctx.fill();
1618
  ctx.lineWidth = 2;
1619
  ctx.strokeStyle = bgAlt;
@@ -1805,34 +1846,30 @@ function showToast(message, isError, onRetry){
1805
  function fmtS(n){ return (Math.round((n||0)*100)/100).toFixed(2) + 's'; }
1806
  function fmtPct(n){ return (n||0).toFixed(1) + '%'; }
1807
 
1808
- // Composites each segment's screenshot with its eye-attention heat layer
1809
- // (the exact same paintHeatLayer()/EYE_STOPS the live Attention Heatmaps
1810
- // panel uses) into one flattened PNG, so print doesn't depend on a live
1811
- // canvas repainting correctly inside the print engine.
1812
- function compositeSegmentImage(seg){
1813
- return new Promise(function(resolve){
1814
- const im = new Image();
1815
- im.onload = function(){
1816
- const cv = document.createElement('canvas');
1817
- cv.width = im.naturalWidth || 1;
1818
- cv.height = im.naturalHeight || 1;
1819
- const cx = cv.getContext('2d');
1820
- cx.drawImage(im, 0, 0);
1821
- try { paintHeatLayer(cx, cv.width, cv.height, seg.points || [], 0.09, EYE_STOPS, 0.9); } catch(e){}
1822
- let dataUrl = seg.screenshotData;
1823
- try { dataUrl = cv.toDataURL('image/png'); } catch(e){}
1824
- resolve({ dataUrl: dataUrl, w: cv.width, h: cv.height });
1825
- };
1826
- im.onerror = function(){ resolve({ dataUrl: seg.screenshotData, w: 16, h: 9 }); };
1827
- im.src = seg.screenshotData;
1828
- });
1829
- }
1830
-
1831
- window.__insightuxPrintReady = Promise.all(SEGMENTS.map(compositeSegmentImage))
1832
  .then(buildPrintPages)
1833
  .catch(function(){ /* best-effort — export still works with whatever got built */ });
1834
 
1835
- function buildPrintPages(composited){
1836
  const BODY_BUDGET = 1025; // must match .pp-body's CSS height (1123 - 46 top - 52 bottom)
1837
  const PAGES = [];
1838
  let curBody = null;
@@ -2047,18 +2084,6 @@ function showToast(message, isError, onRetry){
2047
  newPage();
2048
  fitAppend(sectionHeading('Attention Visualization', 'Visual representation of the recorded gaze attention across webpage elements.'));
2049
 
2050
- if (SEGMENTS.length) {
2051
- const seg = SEGMENTS[0];
2052
- const c = composited[0];
2053
- const shotCard = document.createElement('div');
2054
- shotCard.className = 'pp-card';
2055
- shotCard.innerHTML =
2056
- '<div class="pp-section-title" style="font-size:10.5px;">Session View</div>' +
2057
- '<div class="pp-shot-label"><span>Scroll segment 1 of ' + SEGMENTS.length + '</span><span>~' + seg.duration + 's of attention here</span></div>' +
2058
- '<div class="pp-shot-frame"><img width="' + c.w + '" height="' + c.h + '" src="' + c.dataUrl + '" alt=""></div>';
2059
- fitAppend(shotCard);
2060
- }
2061
-
2062
  const chartCard = document.createElement('div');
2063
  chartCard.className = 'pp-card';
2064
  const chartTitle = document.createElement('div');
@@ -2105,21 +2130,24 @@ function showToast(message, isError, onRetry){
2105
  newPage();
2106
  fitAppend(sectionHeading('Attention Heatmaps', 'Heatmaps show where visual attention was concentrated within the recorded webpage viewport.'));
2107
 
2108
- if (SEGMENTS.length) {
2109
- const perRow = SEGMENTS.length <= 3 ? 1 : 2;
2110
- for (let i = 0; i < SEGMENTS.length; i += perRow) {
2111
- const row = document.createElement('div');
2112
- row.className = 'pp-shot-row pp-cols-' + perRow;
2113
- for (let j = i; j < Math.min(i + perRow, SEGMENTS.length); j++) {
2114
- const seg = SEGMENTS[j], c = composited[j];
2115
- const card = document.createElement('div');
2116
- card.innerHTML =
2117
- '<div class="pp-shot-label"><span>Scroll Segment ' + (j + 1) + '</span><span>~' + seg.duration + 's attention</span></div>' +
2118
- '<div class="pp-shot-frame"><img width="' + c.w + '" height="' + c.h + '" src="' + c.dataUrl + '" alt=""></div>';
2119
- row.appendChild(card);
2120
- }
2121
- fitAppend(row);
2122
- }
 
 
 
2123
  } else {
2124
  const empty = document.createElement('div');
2125
  empty.className = 'pp-card';
@@ -2253,8 +2281,8 @@ function showToast(message, isError, onRetry){
2253
 
2254
  ctx.clearRect(0, 0, cssW, cssH);
2255
 
2256
- ctx.strokeStyle = 'rgba(148,138,179,0.16)';
2257
- ctx.fillStyle = '#6E6885';
2258
  ctx.font = '9px ' + font;
2259
  ctx.textAlign = 'right';
2260
  ctx.textBaseline = 'middle';
@@ -2270,7 +2298,7 @@ function showToast(message, isError, onRetry){
2270
  const xFor = function(i){ return n > 1 ? padL + i * stepX : padL + plotW / 2; };
2271
  const yFor = function(v){ return padT + plotH - (v / top) * plotH; };
2272
 
2273
- ctx.strokeStyle = '#8B5CF6';
2274
  ctx.lineWidth = 2;
2275
  ctx.beginPath();
2276
  items.forEach(function(r, i){
@@ -2282,12 +2310,10 @@ function showToast(message, isError, onRetry){
2282
  const showValues = n <= 10;
2283
  items.forEach(function(r, i){
2284
  const x = xFor(i), y = yFor(r.seconds);
2285
- const grad = ctx.createRadialGradient(x, y, 0, x, y, 5);
2286
- grad.addColorStop(0, '#C084FC'); grad.addColorStop(1, '#6366F1');
2287
- ctx.fillStyle = grad;
2288
  ctx.beginPath(); ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.fill();
2289
  if (showValues) {
2290
- ctx.fillStyle = '#F1EEFA';
2291
  ctx.font = '600 9px ' + font;
2292
  ctx.textAlign = 'center';
2293
  ctx.textBaseline = 'alphabetic';
@@ -2295,13 +2321,13 @@ function showToast(message, isError, onRetry){
2295
  }
2296
  });
2297
 
2298
- ctx.fillStyle = '#9B95B3';
2299
  ctx.font = '8.5px ' + font;
2300
  ctx.textAlign = 'center';
2301
  ctx.textBaseline = 'top';
2302
  items.forEach(function(r, i){ ctx.fillText('#' + (i + 1), xFor(i), padT + plotH + 8); });
2303
 
2304
- ctx.fillStyle = '#6E6885';
2305
  ctx.font = '700 8px ' + font;
2306
  ctx.textAlign = 'center';
2307
  ctx.fillText('WEBPAGE ELEMENTS', padL + plotW / 2, cssH - 8);
@@ -2327,21 +2353,36 @@ def generate_report(session_dir):
2327
  segments = build_screenshot_segments(gaze, dom, session_dir)
2328
  summary = session_summary(gaze, dom)
2329
  mouse = summarize_mouse(session_dir)
2330
- mouse_segments = build_mouse_screenshot_segments(segments, dom, session_dir)
 
2331
 
2332
  top_label = ranking[0]["label"] if ranking else "—"
2333
 
2334
  # session_dir's own folder name is already the session's timestamp
2335
  # (browser_session.py names it that way) — reused here only to print a
2336
- # human-readable date in the header, same convention _recent_sessions_json()
2337
- # already parses on the landing page.
2338
  try:
2339
  session_ts = datetime.strptime(os.path.basename(session_dir.rstrip("/\\")), "%Y%m%d_%H%M%S")
2340
  session_timestamp = session_ts.strftime("%b %d, %Y — %I:%M %p").replace(" 0", " ")
2341
  except ValueError:
2342
  session_timestamp = "Unknown time"
2343
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2344
  html = _TEMPLATE
 
2345
  html = html.replace("__URL__", summary["url"] or "Unknown page")
2346
  html = html.replace("__URL_JSON__", json.dumps(summary["url"] or ""))
2347
  html = html.replace("__SESSION_TIMESTAMP__", session_timestamp)
@@ -2356,7 +2397,8 @@ def generate_report(session_dir):
2356
  html = html.replace("__SEGMENTS_JSON__", json.dumps(segments))
2357
  html = html.replace("__MOUSE_INTERESTS_JSON__", json.dumps(mouse["interests"]))
2358
  html = html.replace("__MOUSE_CLICKS_JSON__", json.dumps(mouse["clicks"]))
2359
- html = html.replace("__MOUSE_SEGMENTS_JSON__", json.dumps(mouse_segments))
 
2360
 
2361
  html = html.replace("__THEME_CSS__", theme.THEME_CSS)
2362
  html = html.replace("__THEME_JS__", theme.THEME_TOGGLE_JS)
 
19
  import json
20
  import bisect
21
  import base64
22
+ import html as html_escape
23
  from datetime import datetime
24
 
25
  import theme
 
300
  continue
301
  viewport = ev.get("viewport") or {}
302
  segments.append({
303
+ "screenshot": ev["screenshot"], # relative path — kept for traceability/debugging only, not consumed by the report
304
  "screenshotData": data_uri, # what the report's <img>/canvas actually load
305
  "scrollY": ev.get("scrollY", 0),
306
  "viewportW": viewport.get("w"),
307
  "viewportH": viewport.get("h"),
308
  "points": pts,
309
+ "stickyRects": ev.get("stickyRects") or [], # viewport-relative CSS rects of position:fixed/sticky elements at capture time — lets the stitcher paste each one once instead of once per screenshot
310
  "duration": 0.0, # filled below
311
  })
312
 
 
321
 
322
 
323
  # =============================================================================
324
+ # FULL-PAGE HEATMAP DATA (whole-session, not per scroll position). Every
325
+ # point below was already written to mouse_log.jsonl by the existing
326
+ # MOUSE_JS sampling loop and Api.log_mouse_data(); summarize_mouse() above
327
+ # is unchanged and still powers the interests table / click log / click-
328
+ # timeline exactly as before. This just surfaces the raw coordinates so the
329
+ # report's client-side stitcher can plot every mouse point ever recorded
330
+ # onto one continuous full-page image.
 
 
 
 
 
331
  # =============================================================================
332
 
333
+ def build_mouse_fullpage_points(session_dir):
334
+ """Every mouse trail/heatmap/click point recorded this session, in the
335
+ same page-absolute CSS-pixel space MOUSE_JS already samples them in
336
+ (clientX/clientY + scrollX/scrollY) no per-segment bucketing needed,
337
+ since page-absolute coordinates don't depend on which screenshot was on
338
+ screen when the point was captured."""
339
+ points = []
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  for b in load_mouse_batches(session_dir):
 
 
 
 
 
 
 
341
  for p in (b.get("trail") or []):
342
+ points.append({"x": round(p["x"], 1), "y": round(p["y"], 1)})
343
  for p in (b.get("heatmap") or []):
344
+ points.append({"x": round(p["x"], 1), "y": round(p["y"], 1)})
345
  for c in (b.get("click") or []):
346
+ points.append({"x": round(c["x"], 1), "y": round(c["y"], 1), "w": 5})
347
+ return points
348
 
349
+
350
+ def full_page_dims(dom):
351
+ """Largest page width/height seen across every dom snapshot (CSS px) —
352
+ lets the client size its stitched canvas to the true full page even if
353
+ the last screenshot captured doesn't reach the page bottom."""
354
+ w = max(((d.get("page") or {}).get("w") or 0) for d in dom) if dom else 0
355
+ h = max(((d.get("page") or {}).get("h") or 0) for d in dom) if dom else 0
356
+ return {"w": w, "h": h}
357
 
358
 
359
  # =============================================================================
 
377
  .topbar .brand { display: flex; align-items: center; gap: 10px; }
378
  .topbar .mark {
379
  width: 34px; height: 34px; border-radius: 10px; background: var(--iux-accent-grad);
380
+ display: flex; align-items: center; justify-content: center; color: var(--iux-on-accent); flex-shrink: 0;
381
  }
382
  h1 { margin: 0; font-size: 19px; font-weight: 700; }
383
  .sub { color: var(--iux-text-dim); font-size: 12.5px; margin-top: 2px; }
 
390
  }
391
  .stat .icon-badge {
392
  width: 30px; height: 30px; border-radius: 9px; display: flex; align-items: center; justify-content: center;
393
+ background: var(--iux-accent-grad); color: var(--iux-on-accent); margin-bottom: 10px;
394
  }
395
  .stat .v { font-size: 21px; font-weight: 700; color: var(--iux-text); }
396
  .stat .l { font-size: 10.5px; color: var(--iux-text-faint); text-transform: uppercase; letter-spacing: .05em; margin-top: 2px; }
 
438
  border: none; background: transparent; color: var(--iux-text-dim); padding: 6px 12px; border-radius: 999px;
439
  font-size: 11.5px; cursor: pointer; font-family: var(--iux-font);
440
  }
441
+ .hm-toolbar .seg button.on { background: var(--iux-accent-grad); color: var(--iux-on-accent); }
442
  .hm-toolbar .slider-mini { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--iux-text-faint); }
443
  .hm-toolbar .slider-mini input { accent-color: var(--iux-primary-light); }
444
  .hm-toolbar .iux-btn { padding: 7px 10px; font-size: 11.5px; margin-left: auto; }
 
447
  .shot-wrap:hover { box-shadow: var(--iux-shadow-glow); }
448
  .shot-wrap img, .shot-wrap canvas { display: block; width: 100%; height: auto; }
449
  .shot-wrap canvas { position: absolute; top: 0; left: 0; }
450
+ .shot-caption { margin-top: 10px; font-size: 12px; color: var(--iux-text-dim); text-align: center; }
 
 
 
451
 
452
  .click-timeline { width: 100%; height: 60px; display: block; margin-top: 8px; }
453
 
 
457
  .elem-chart-heading { display: flex; align-items: center; gap: 12px; }
458
  .elem-chart-heading .icon-badge {
459
  width: 34px; height: 34px; border-radius: 10px; display: flex; align-items: center; justify-content: center;
460
+ background: var(--iux-accent-grad); color: var(--iux-on-accent); flex-shrink: 0;
461
  }
462
  .elem-chart-heading h2 {
463
  margin: 0; font-size: 12.5px; color: var(--iux-text-dim); font-weight: 700;
 
480
  cursor: pointer; text-align: left;
481
  }
482
  .elem-view-opt:hover { background: var(--iux-surface); color: var(--iux-text); }
483
+ .elem-view-opt.on { background: var(--iux-accent-grad); color: var(--iux-on-accent); }
484
 
485
  .elem-chart-wrap { position: relative; }
486
  .elem-chart-scroll {
 
511
  .elem-fullscreen.open { display: flex; }
512
  .elem-fullscreen-bar {
513
  display: flex; align-items: center; gap: 14px; padding: 14px 20px; flex-wrap: wrap;
514
+ background: rgba(27,25,24,0.9); border-bottom: 1px solid rgba(255,255,255,0.1); flex-shrink: 0;
515
  }
516
+ .elem-fullscreen-title { color: #f9f8f3; font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 8px; margin-right: auto; }
517
  .elem-fullscreen-controls-slot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
518
+ .elem-fullscreen-bar .iux-btn { color: #f9f8f3; background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.14); }
519
  .elem-fullscreen-bar .iux-btn:hover { background: rgba(255,255,255,0.18); }
520
+ .elem-fullscreen-bar .elem-view-menu { background: #332E2A; }
521
  .elem-fullscreen-body { flex: 1 1 auto; padding: 24px; overflow: auto; }
522
  .elem-fullscreen-body .elem-summary-row { max-width: 900px; }
523
 
 
525
 
526
  .iux-viewer {
527
  position: fixed; inset: 0; z-index: 2000000; display: none; flex-direction: column;
528
+ background: rgba(14,12,10,0.92); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
529
  }
530
  .iux-viewer.open { display: flex; }
531
  .iv-topbar {
532
  display: flex; align-items: center; gap: 10px; padding: 12px 18px;
533
+ background: rgba(27,25,24,0.9); border-bottom: 1px solid rgba(255,255,255,0.1); flex-shrink: 0;
534
  }
535
+ .iv-topbar .iux-btn { color: #f9f8f3; background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.14); }
536
  .iv-topbar .iux-btn:hover { background: rgba(255,255,255,0.18); }
537
+ .iv-title { color: #f9f8f3; font-size: 13px; font-weight: 600; margin-right: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
538
+ .iv-zoom-pct { color: #FFE9A8; font-size: 12px; min-width: 44px; text-align: center; flex-shrink: 0; }
539
  .iv-stage { flex: 1 1 auto; position: relative; overflow: hidden; cursor: grab; }
540
  .iv-stage.grabbing { cursor: grabbing; }
541
  #ivImage { position: absolute; top: 50%; left: 50%; max-width: none; user-select: none; -webkit-user-select: none; }
542
  .iv-nav {
543
  position: absolute; top: 50%; transform: translateY(-50%); z-index: 5;
544
  width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
545
+ color: #f9f8f3; background: rgba(27,25,24,0.65); border-color: rgba(255,255,255,0.16);
546
  }
547
+ .iv-nav:hover { background: rgba(255,233,168,0.55); }
548
  .iv-nav-prev { left: 16px; }
549
  .iv-nav-next { right: 16px; }
550
  .iv-nav[disabled] { display: none; }
551
  .iv-bottombar {
552
  display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px 18px;
553
+ background: rgba(27,25,24,0.9); border-top: 1px solid rgba(255,255,255,0.1); flex-shrink: 0; flex-wrap: wrap;
554
  }
555
+ .iv-bottombar .iux-btn { color: #f9f8f3; background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.14); padding: 7px 12px; font-size: 12px; }
556
  .iv-bottombar .iux-btn:hover { background: rgba(255,255,255,0.18); }
557
  .iv-bottombar input[type=range] { accent-color: var(--iux-primary-light); width: 120px; }
558
 
 
573
  }
574
  @page { size: A4; margin: 0; }
575
  @media print {
576
+ html, body { background: #1B1918 !important; }
577
  body > .back-nav, body > .topbar, body > .stat-row, body > .grid,
578
  #iuxViewer, #elemFullscreen, #iuxReportToast { display: none !important; }
579
  #printReport { position: static !important; left: auto !important; width: auto !important; }
 
581
 
582
  .print-page {
583
  position: relative; width: 794px; height: 1123px;
584
+ background: #1B1918; color: #F9F8F3;
585
  box-sizing: border-box; overflow: hidden;
586
  break-after: page; page-break-after: always;
587
  font-family: var(--iux-font);
 
589
  .print-page:last-child { break-after: auto; page-break-after: auto; }
590
 
591
  .pp-accent { position: absolute; top: 0; left: 0; right: 0; height: 6px;
592
+ background: #FFE9A8; }
593
  .pp-header { position: absolute; top: 16px; left: 46px; right: 46px; height: 20px;
594
  display: flex; align-items: center; justify-content: space-between; }
595
+ .pp-header .pp-brand { font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; color: #F9F8F3; }
596
+ .pp-header .pp-tag { font-size: 9px; font-weight: 600; letter-spacing: 0.12em; color: #8E8E92; text-transform: uppercase; }
597
  .pp-body { position: absolute; top: 46px; left: 46px; right: 46px; bottom: 52px; overflow: hidden; }
598
  .pp-footer { position: absolute; bottom: 0; left: 46px; right: 46px; height: 40px;
599
  display: flex; align-items: center; justify-content: space-between;
600
+ border-top: 1px solid rgba(198,188,178,0.16); font-size: 8.5px; color: #8E8E92; }
601
 
602
+ .pp-cover-title { font-size: 25px; font-weight: 700; color: #F9F8F3; margin: 8px 0 4px; }
603
+ .pp-cover-meta { font-size: 10.5px; color: #C6BCB2; margin-bottom: 20px; }
604
 
605
  .pp-metric-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 18px; }
606
+ .pp-metric { background: #292522; border: 1px solid rgba(198,188,178,0.16); border-radius: 12px; padding: 12px 14px; break-inside: avoid; }
607
+ .pp-metric .pp-m-label { font-size: 8.5px; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: #8E8E92; margin-bottom: 6px; }
608
+ .pp-metric .pp-m-value { font-size: 19px; font-weight: 700; color: #F9F8F3; }
609
+ .pp-metric .pp-m-sub { font-size: 8.5px; color: #8E8E92; margin-top: 2px; }
610
+
611
+ .pp-card { background: #292522; border: 1px solid rgba(198,188,178,0.16); border-radius: 12px; padding: 16px 18px; margin-bottom: 14px; break-inside: avoid; }
612
+ .pp-section-title { font-size: 11.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: #F9F8F3; margin: 0 0 10px; }
613
+ .pp-section-sub { font-size: 10px; color: #C6BCB2; margin: -6px 0 12px; line-height: 1.5; }
614
+ .pp-body-text { font-size: 10.5px; color: #D6CFC5; line-height: 1.65; }
615
+ .pp-body-text b { color: #F9F8F3; }
616
  .pp-two-col { display: grid; grid-template-columns: 1.35fr 1fr; gap: 14px; }
617
 
618
  .pp-table { width: 100%; border-collapse: collapse; font-size: 10px; }
619
+ .pp-table th { text-align: left; color: #8E8E92; font-weight: 700; padding: 6px 8px; font-size: 8.5px;
620
+ text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid rgba(198,188,178,0.25); }
621
+ .pp-table td { padding: 7px 8px; border-bottom: 1px solid rgba(198,188,178,0.12); vertical-align: middle; }
622
+ .pp-table tr:nth-child(even) td { background: rgba(198,188,178,0.05); }
623
  .pp-table .pp-num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
624
+ .pp-table .pp-rank { color: #8E8E92; width: 22px; }
625
  .pp-table .pp-el-name { word-break: break-word; }
626
 
627
  .pp-snap-item { margin-bottom: 12px; }
628
+ .pp-snap-item .pp-snap-label { font-size: 8.5px; text-transform: uppercase; letter-spacing: 0.05em; color: #8E8E92; margin-bottom: 3px; }
629
+ .pp-snap-item .pp-snap-value { font-size: 13px; font-weight: 700; color: #F9F8F3; }
630
 
631
+ .pp-shot-frame { width: 100%; border-radius: 10px; overflow: hidden; border: 1px solid rgba(198,188,178,0.2); background: #221F1D; }
632
  .pp-shot-frame img { display: block; width: 100%; height: auto; }
633
+ .pp-shot-label { font-size: 9.5px; color: #C6BCB2; margin-bottom: 8px; display: flex; justify-content: space-between; }
634
  .pp-shot-row { display: grid; gap: 12px; margin-bottom: 14px; break-inside: avoid; }
635
  .pp-shot-row.pp-cols-1 { grid-template-columns: 1fr; }
636
  .pp-shot-row.pp-cols-2 { grid-template-columns: 1fr 1fr; }
637
 
638
+ .pp-chart-caption { font-size: 9px; color: #8E8E92; margin-top: 8px; text-align: center; }
639
 
640
  .pp-detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
641
+ .pp-detail-card { background: #221F1D; border: 1px solid rgba(198,188,178,0.14); border-radius: 10px; padding: 10px 12px; break-inside: avoid; }
642
+ .pp-detail-card .pp-d-name { font-size: 10.5px; font-weight: 600; color: #F9F8F3; margin-bottom: 6px; }
643
+ .pp-detail-card .pp-d-row { display: flex; justify-content: space-between; font-size: 9px; color: #C6BCB2; padding: 2px 0; }
644
+ .pp-detail-card .pp-d-row b { color: #F9F8F3; font-weight: 600; }
645
 
646
+ .pp-click-item { border-bottom: 1px solid rgba(198,188,178,0.12); padding: 7px 0; font-size: 10px; break-inside: avoid; }
647
+ .pp-click-item .pp-click-time { font-size: 8.5px; color: #8E8E92; }
648
+ .pp-click-item .pp-click-el { font-weight: 600; color: #F9F8F3; }
649
+ .pp-click-item .pp-click-text { color: #C6BCB2; font-size: 9.5px; }
650
 
651
  .pp-insight-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
652
+ .pp-insight-card { background: #292522; border: 1px solid rgba(198,188,178,0.16); border-radius: 12px; padding: 16px; break-inside: avoid; }
653
+ .pp-insight-card .pp-i-label { font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; color: #8E8E92; margin-bottom: 6px; }
654
+ .pp-insight-card .pp-i-value { font-size: 17px; font-weight: 700; color: #F9F8F3; }
655
 
656
+ .pp-empty { font-size: 10.5px; color: #8E8E92; padding: 10px 0; }
657
  </style>
658
  </head>
659
  <body>
 
665
  <div class="mark">__EYE_ICON__</div>
666
  <div>
667
  <h1>InsightUX Session Report</h1>
668
+ <div class="sub">__SUBJECT_LABEL____URL__ &nbsp;&middot;&nbsp; __SESSION_TIMESTAMP__ &nbsp;&middot;&nbsp; __DURATION__s &nbsp;&middot;&nbsp; __SAMPLES__ gaze samples</div>
669
  </div>
670
  </div>
671
  <div class="actions">
 
699
  <div class="legend-scale">
700
  <span>Little</span><div class="bar"></div><span>A lot</span>
701
  </div>
702
+ <p>This is the entire page stitched into one continuous image from
703
+ every screenshot captured during the session, so you can see attention
704
+ across the whole layout at a glance instead of one scroll position at
705
+ a time. Use the maximize/download buttons above to view or save it.</p>
706
  </div>
707
  </div>
708
  <div class="panel iux-card full iux-fade-in">
 
812
  const SEGMENTS = __SEGMENTS_JSON__;
813
  const MOUSE_INTERESTS = __MOUSE_INTERESTS_JSON__;
814
  const MOUSE_CLICKS = __MOUSE_CLICKS_JSON__;
815
+ const MOUSE_FULLPAGE = __MOUSE_FULLPAGE_JSON__;
816
+ const PAGE_DIMS = __PAGE_DIMS_JSON__;
817
  const MAX_ATTENTION_ITEMS = __MAX_ATTENTION_ITEMS__;
818
  const SESSION = {
819
  url: __URL_JSON__,
 
1054
  if (MOUSE_CLICKS.length > 1) {
1055
  const times = MOUSE_CLICKS.map(c => { const p = (c.timestamp||'').split(':').map(Number); return (p[0]||0)*3600+(p[1]||0)*60+(p[2]||0); });
1056
  const tmin = Math.min(...times), tmax = Math.max(...times) || tmin + 1;
1057
+ ctx.strokeStyle = 'rgba(255,233,168,0.25)';
1058
  ctx.beginPath(); ctx.moveTo(0, cv.height - 10); ctx.lineTo(cv.width, cv.height - 10); ctx.stroke();
1059
  times.forEach(function(t){
1060
  const x = ((t - tmin) / (tmax - tmin || 1)) * (cv.width - 12) + 6;
1061
+ ctx.fillStyle = '#FFE9A8';
 
 
1062
  ctx.beginPath(); ctx.arc(x, cv.height - 10, 4, 0, 2*Math.PI); ctx.fill();
1063
  });
1064
  }
 
1119
  [1.00, 255, 45, 0],
1120
  ];
1121
  const MOUSE_STOPS = [
1122
+ [0.00, 201, 138, 46],
1123
+ [0.35, 253, 217, 98],
1124
+ [0.70, 255, 233, 168],
1125
  [1.00, 255, 255, 255],
1126
  ];
1127
  const HEAT_RADIUS = 22; // was 55/60 — smaller, more precise fixation spots
 
1199
  toast._hideTimer = setTimeout(function(){ toast.style.display = 'none'; }, onRetry ? 6000 : 3200);
1200
  }
1201
 
1202
+ // ---------- Full-page stitch: combines every scroll-position screenshot
1203
+ // into one continuous canvas, and every gaze/mouse point ever recorded into
1204
+ // one flat point list in that same canvas's coordinate space. Shared by the
1205
+ // live Attention Heatmaps panel and the print/export report so both show
1206
+ // the identical whole-page picture — the result is cached (the screenshots
1207
+ // never change after the report is built) so it's only ever stitched once.
1208
+ //
1209
+ // Mouse/DOM coordinates (scrollY, click/trail x-y) are recorded in CSS
1210
+ // pixels by the tracked page itself; gaze sx/sy and the screenshots are
1211
+ // physical screen pixels (pyautogui.screenshot()). Those only match 1:1 at
1212
+ // 100% OS display scale — dprFromViewport() recovers the real ratio from
1213
+ // the screenshot's actual decoded size vs. the CSS viewport width recorded
1214
+ // alongside it, so every point lands on the spot it was actually at instead
1215
+ // of being compressed toward the top-left on scaled displays.
1216
+ // ----------
1217
+ function dprFromViewport(vw, img){
1218
+ if (!vw || !img || !img.naturalWidth) return 1;
1219
+ const d = img.naturalWidth / vw;
1220
+ return (isFinite(d) && d > 0) ? d : 1;
1221
+ }
1222
+
1223
+ let _fullPageStitchPromise = null;
1224
+ function buildFullPageStitch(){
1225
+ if (_fullPageStitchPromise) return _fullPageStitchPromise;
1226
+ _fullPageStitchPromise = new Promise(function(resolve, reject){
1227
+ if (!SEGMENTS.length){ reject(new Error('no screenshots to stitch')); return; }
1228
+ const probe = new Image();
1229
+ probe.onload = function(){
1230
+ const dpr = dprFromViewport(SEGMENTS[0].viewportW, probe);
1231
+ let canvasH = Math.round((PAGE_DIMS.h || 0) * dpr);
1232
+ SEGMENTS.forEach(function(s){
1233
+ canvasH = Math.max(canvasH, Math.round(s.scrollY * dpr) + probe.naturalHeight);
1234
+ });
1235
+ const canvasW = probe.naturalWidth;
1236
+
1237
+ // Load every segment's screenshot, then paste each at its recorded
1238
+ // scrollY (converted to canvas px) — in capture order, so the most
1239
+ // recently-seen state of any overlapping band wins, same as any
1240
+ // scroll-and-stitch full-page capture tool.
1241
+ Promise.all(SEGMENTS.map(function(s){
1242
+ return new Promise(function(res){
1243
+ const im = new Image();
1244
+ im.onload = function(){ res({ im: im, scrollY: s.scrollY, stickyRects: s.stickyRects || [] }); };
1245
+ im.onerror = function(){ res(null); };
1246
+ im.src = s.screenshotData;
1247
+ });
1248
+ })).then(function(loaded){
1249
+ const bg = document.createElement('canvas');
1250
+ bg.width = canvasW;
1251
+ bg.height = canvasH;
1252
+ const bgCtx = bg.getContext('2d');
1253
+ loaded.filter(Boolean).forEach(function(item, i){
1254
+ const y = Math.round(item.scrollY * dpr);
1255
+ if (i === 0 || !item.stickyRects.length){
1256
+ bgCtx.drawImage(item.im, 0, y);
1257
+ return;
1258
+ }
1259
+ // Elements with position:fixed/sticky (a floating nav tab, a
1260
+ // persistent CTA) show up at the same on-screen spot in every
1261
+ // screenshot regardless of scroll depth — segment 0 already drew
1262
+ // it once. Clipping it out of every later, overlapping paste (a
1263
+ // rect-with-a-hole clip path, even-odd fill rule) stops each one
1264
+ // from stamping another copy on top, which is what produced the
1265
+ // "duplicated"/ghosted look a naive scroll-and-stitch gets on
1266
+ // pages with fixed UI.
1267
+ bgCtx.save();
1268
+ bgCtx.beginPath();
1269
+ bgCtx.rect(0, y, canvasW, item.im.naturalHeight);
1270
+ item.stickyRects.forEach(function(r){
1271
+ bgCtx.rect(r.x * dpr, y + r.y * dpr, r.w * dpr, r.h * dpr);
1272
+ });
1273
+ bgCtx.clip('evenodd');
1274
+ bgCtx.drawImage(item.im, 0, y);
1275
+ bgCtx.restore();
1276
+ });
1277
+
1278
+ const gazePoints = [];
1279
+ SEGMENTS.forEach(function(s){
1280
+ const offY = Math.round(s.scrollY * dpr);
1281
+ (s.points || []).forEach(function(p){ gazePoints.push({ sx: p.sx, sy: p.sy + offY }); });
1282
+ });
1283
+ const mousePoints = MOUSE_FULLPAGE.map(function(p){
1284
+ return { sx: p.x * dpr, sy: p.y * dpr, w: p.w };
1285
+ });
1286
+
1287
+ resolve({ canvas: bg, width: canvasW, height: canvasH, dpr: dpr, gazePoints: gazePoints, mousePoints: mousePoints });
1288
+ });
1289
+ };
1290
+ probe.onerror = function(){ reject(new Error('could not load a screenshot to stitch')); };
1291
+ probe.src = SEGMENTS[0].screenshotData;
1292
+ });
1293
+ return _fullPageStitchPromise;
1294
+ }
1295
+
1296
+ // ---------- Attention Heatmaps panel: Eye / Mouse / Combined tabs over one
1297
+ // full-page stitched image (built by buildFullPageStitch() above) ----------
1298
  (function(){
1299
  const area = document.getElementById('heatmapArea');
1300
  const toolbar = document.getElementById('hmToolbar');
 
1305
  }
1306
  toolbar.style.display = 'flex';
1307
 
 
1308
  let mode = 'eye'; // 'eye' | 'mouse' | 'combined'
1309
  const heatState = {
1310
  eye: { opacity: 0.9, intensity: 0.09 },
 
1312
  combined: { opacity: 0.9, intensity: 0.10 },
1313
  };
1314
 
1315
+ area.innerHTML = `
1316
+ <div class="shot-wrap" id="shotWrap" title="Click to open in viewer">
1317
+ <img id="shotImg" alt="">
1318
+ <canvas id="shotCanvas"></canvas>
1319
+ </div>
1320
+ <div class="shot-caption" id="shotCaption">Stitching the full page&hellip;</div>
1321
+ `;
1322
+
1323
+ const img = document.getElementById('shotImg');
1324
+ const cv = document.getElementById('shotCanvas');
1325
+ const ctx = cv.getContext('2d');
1326
+ document.getElementById('shotWrap').addEventListener('click', function(){ openFullPageViewer(); });
1327
+
1328
+ let stitch = null;
1329
+
1330
+ function pointsFor(kind){
1331
+ if (!stitch) return [];
1332
+ return kind === 'eye' ? stitch.gazePoints : stitch.mousePoints;
1333
+ }
1334
+
1335
+ function paintActive(){
1336
+ if (!stitch) return;
1337
+ ctx.clearRect(0, 0, cv.width, cv.height);
 
 
1338
  const st = heatState[mode];
1339
  if (mode === 'eye'){
1340
+ paintHeatLayer(ctx, cv.width, cv.height, pointsFor('eye'), st.intensity, EYE_STOPS, st.opacity);
1341
  } else if (mode === 'mouse'){
1342
+ paintHeatLayer(ctx, cv.width, cv.height, pointsFor('mouse'), st.intensity, MOUSE_STOPS, st.opacity);
1343
  } else {
1344
+ paintHeatLayer(ctx, cv.width, cv.height, pointsFor('eye'), st.intensity, EYE_STOPS, st.opacity);
1345
+ paintHeatLayer(ctx, cv.width, cv.height, pointsFor('mouse'), st.intensity, MOUSE_STOPS, st.opacity, 'lighter');
1346
  }
1347
  }
1348
 
 
1352
  document.querySelectorAll('.hm-tab').forEach(b => b.classList.toggle('on', b === btn));
1353
  document.getElementById('hmOpacity').value = Math.round(heatState[mode].opacity * 100);
1354
  document.getElementById('hmIntensity').value = Math.round(heatState[mode].intensity * 33);
1355
+ paintActive();
1356
  });
1357
  });
1358
  document.getElementById('hmOpacity').addEventListener('input', function(e){
1359
  heatState[mode].opacity = e.target.value / 100;
1360
+ paintActive();
1361
  });
1362
  document.getElementById('hmIntensity').addEventListener('input', function(e){
1363
  heatState[mode].intensity = e.target.value / 33;
1364
+ paintActive();
1365
  });
1366
+
1367
+ // Flattens the visible full-page image + its heat overlay into ONE opaque
1368
  // canvas. The on-screen view layers a transparent <canvas> on top of an
1369
  // <img> purely via CSS positioning — exporting the heat canvas alone (as
1370
  // earlier code did) produces a mostly-transparent PNG that most viewers
1371
  // render as solid black. This is the single source both the fullscreen
1372
  // viewer and the direct download button use, so both always show the
1373
  // real composited picture.
1374
+ function composeFullPageCanvas(){
1375
+ if (!stitch || !img.complete || !img.naturalWidth) return null;
 
 
1376
  const out = document.createElement('canvas');
1377
+ out.width = img.naturalWidth;
1378
+ out.height = img.naturalHeight;
1379
  const octx = out.getContext('2d');
1380
  octx.fillStyle = '#ffffff';
1381
  octx.fillRect(0, 0, out.width, out.height);
1382
+ octx.drawImage(img, 0, 0);
1383
+ octx.drawImage(cv, 0, 0);
1384
  return out;
1385
  }
1386
 
1387
+ function fullPageTitle(){
1388
+ return 'Full Page — ' + mode.charAt(0).toUpperCase() + mode.slice(1) + ' heatmap';
1389
  }
1390
+ function fullPageDownloadName(){
1391
+ return 'insightux-' + mode + '-heatmap-fullpage.png';
1392
  }
1393
 
1394
+ function openFullPageViewer(){
1395
+ const canvas = composeFullPageCanvas();
1396
+ if (!canvas) {
1397
+ showToast('Could not prepare the full-page heatmap for viewing.', true, openFullPageViewer);
1398
+ return;
1399
+ }
1400
+ Viewer.open({
1401
+ source: canvas,
1402
+ title: fullPageTitle(),
1403
+ downloadName: fullPageDownloadName(),
1404
+ onPrev: null,
1405
+ onNext: null,
 
 
 
 
 
 
 
1406
  });
1407
  }
1408
 
1409
+ document.getElementById('hmFullscreen').addEventListener('click', openFullPageViewer);
1410
  document.getElementById('hmDownload').addEventListener('click', function(){
1411
+ const canvas = composeFullPageCanvas();
 
1412
  if (!canvas) {
1413
+ showToast('Could not prepare the full-page heatmap for export.', true);
1414
  return;
1415
  }
1416
  try {
1417
  const a = document.createElement('a');
1418
  a.href = canvas.toDataURL('image/png');
1419
+ a.download = fullPageDownloadName();
1420
  a.click();
1421
  } catch (err) {
1422
+ console.warn('[insightux-report] full-page download failed:', err);
1423
  showToast('Could not export this image.', true);
1424
  }
1425
  });
1426
 
1427
+ function renderFail(){
1428
+ const shotWrap = document.getElementById('shotWrap');
1429
+ if (shotWrap) {
1430
+ shotWrap.innerHTML =
1431
+ '<div class="empty"><span class="ei">' + iuxIcon('info', 26) + '</span>' +
1432
+ 'Could not stitch the full-page heatmap for this session.<br>' +
1433
+ '<button type="button" class="iux-btn" id="retryShotBtn" style="margin-top:10px;">' +
1434
+ iuxIcon('refresh', 13) + ' Retry</button></div>';
1435
+ shotWrap.removeAttribute('title');
1436
+ const retryBtn = document.getElementById('retryShotBtn');
1437
+ if (retryBtn) retryBtn.addEventListener('click', function(e){ e.stopPropagation(); load(); });
1438
+ }
1439
+ }
1440
+
1441
+ function load(){
1442
+ buildFullPageStitch().then(function(result){
1443
+ stitch = result;
1444
+ img.onload = function(){
1445
+ cv.width = stitch.width;
1446
+ cv.height = stitch.height;
1447
+ paintActive();
1448
+ };
1449
+ img.src = stitch.canvas.toDataURL('image/png');
1450
+ const caption = document.getElementById('shotCaption');
1451
+ if (caption) {
1452
+ caption.textContent = SESSION.duration + 's session · ' + SESSION.samples + ' gaze samples · ' + MOUSE_FULLPAGE.length + ' mouse points';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1453
  }
1454
+ }).catch(renderFail);
 
 
 
1455
  }
1456
 
1457
+ load();
1458
  })();
1459
 
1460
  // ---------- Element Attention Analysis: plots EVERY row of the already-
 
1498
  let hoverIdx = -1;
1499
  let animProgress = 0;
1500
  let raf = null;
1501
+ // Re-read from --iux-indigo/--iux-primary-light on every paint() so the
1502
+ // chart follows the live theme toggle instead of locking in one value —
1503
+ // primary-light in particular differs between dark (bright yellow) and
1504
+ // light (deep goldenrod) so it stays legible against bgAlt either way.
1505
+ let accent = '#FFE9A8';
1506
+ let accentLight = '#FFE9A8';
1507
 
1508
  const MIN_SLOT = 60; // generous px per element — chart scrolls instead of compressing
1509
 
 
1565
  ctx.lineTo(pts[pts.length - 1].x, g.padTop + g.plotH);
1566
  ctx.closePath();
1567
  const areaGrad = ctx.createLinearGradient(0, g.padTop, 0, g.padTop + g.plotH);
1568
+ areaGrad.addColorStop(0, 'rgba(255,233,168,0.30)');
1569
+ areaGrad.addColorStop(1, 'rgba(255,233,168,0)');
1570
  ctx.fillStyle = areaGrad;
1571
  ctx.fill();
1572
  }
 
1579
  ctx.quadraticCurveTo(cur.x, cur.y, midX, midY);
1580
  }
1581
  ctx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y);
1582
+ ctx.strokeStyle = accent;
1583
  ctx.lineWidth = 2.5;
1584
  ctx.lineJoin = 'round';
1585
  ctx.lineCap = 'round';
 
1595
  const h = bottom - top;
1596
  if (h <= 0) return;
1597
  const grad = ctx.createLinearGradient(0, top, 0, bottom);
1598
+ grad.addColorStop(0, accentLight);
1599
+ grad.addColorStop(1, accent);
1600
  ctx.fillStyle = grad;
1601
  const r = Math.min(6, barW / 2, h);
1602
  ctx.beginPath();
 
1612
  }
1613
 
1614
  function paint(){
1615
+ const dim = getComputedStyle(document.documentElement).getPropertyValue('--iux-text-dim').trim() || '#c6bcb2';
1616
+ const faint = getComputedStyle(document.documentElement).getPropertyValue('--iux-text-faint').trim() || '#8e8e92';
1617
+ const border = getComputedStyle(document.documentElement).getPropertyValue('--iux-border').trim() || 'rgba(198,188,178,0.16)';
1618
+ const bgAlt = getComputedStyle(document.documentElement).getPropertyValue('--iux-bg-alt').trim() || '#221F1D';
1619
+ const strong = document.documentElement.getAttribute('data-theme') === 'light' ? '#312F2E' : '#F9F8F3';
1620
+ accent = getComputedStyle(document.documentElement).getPropertyValue('--iux-indigo').trim() || '#FFE9A8';
1621
+ accentLight = getComputedStyle(document.documentElement).getPropertyValue('--iux-primary-light').trim() || '#FFE9A8';
1622
  const g = geometry();
1623
 
1624
  // Opaque fill first: this canvas is also what Export reads directly,
 
1654
  const isHover = i === hoverIdx;
1655
  ctx.beginPath();
1656
  ctx.arc(pt.x, pt.y, isHover ? 6.5 : 4, 0, 2 * Math.PI);
1657
+ ctx.fillStyle = isHover ? accentLight : accent;
1658
  ctx.fill();
1659
  ctx.lineWidth = 2;
1660
  ctx.strokeStyle = bgAlt;
 
1846
  function fmtS(n){ return (Math.round((n||0)*100)/100).toFixed(2) + 's'; }
1847
  function fmtPct(n){ return (n||0).toFixed(1) + '%'; }
1848
 
1849
+ // Composites the whole-session full-page stitch (buildFullPageStitch(),
1850
+ // shared with the live Attention Heatmaps panel) with its eye-attention
1851
+ // heat layer (the exact same paintHeatLayer()/EYE_STOPS) into one
1852
+ // flattened PNG, so print doesn't depend on a live canvas repainting
1853
+ // correctly inside the print engine.
1854
+ function compositeFullPageImage(){
1855
+ return buildFullPageStitch().then(function(stitch){
1856
+ const cv = document.createElement('canvas');
1857
+ cv.width = stitch.width || 1;
1858
+ cv.height = stitch.height || 1;
1859
+ const cx = cv.getContext('2d');
1860
+ cx.drawImage(stitch.canvas, 0, 0);
1861
+ try { paintHeatLayer(cx, cv.width, cv.height, stitch.gazePoints || [], 0.09, EYE_STOPS, 0.9); } catch(e){}
1862
+ let dataUrl = null;
1863
+ try { dataUrl = cv.toDataURL('image/png'); } catch(e){}
1864
+ return { dataUrl: dataUrl, w: cv.width, h: cv.height };
1865
+ }).catch(function(){ return null; });
1866
+ }
1867
+
1868
+ window.__insightuxPrintReady = compositeFullPageImage()
 
 
 
 
1869
  .then(buildPrintPages)
1870
  .catch(function(){ /* best-effort — export still works with whatever got built */ });
1871
 
1872
+ function buildPrintPages(fullPage){
1873
  const BODY_BUDGET = 1025; // must match .pp-body's CSS height (1123 - 46 top - 52 bottom)
1874
  const PAGES = [];
1875
  let curBody = null;
 
2084
  newPage();
2085
  fitAppend(sectionHeading('Attention Visualization', 'Visual representation of the recorded gaze attention across webpage elements.'));
2086
 
 
 
 
 
 
 
 
 
 
 
 
 
2087
  const chartCard = document.createElement('div');
2088
  chartCard.className = 'pp-card';
2089
  const chartTitle = document.createElement('div');
 
2130
  newPage();
2131
  fitAppend(sectionHeading('Attention Heatmaps', 'Heatmaps show where visual attention was concentrated within the recorded webpage viewport.'));
2132
 
2133
+ if (fullPage && fullPage.dataUrl && fullPage.w && fullPage.h) {
2134
+ // Shrunk to fit within whatever body space is left on this page —
2135
+ // the stitched full-page image is usually far taller than one A4
2136
+ // page, so it's scaled down as a whole (poster-style) rather than
2137
+ // sliced across several pages.
2138
+ const usedH = curBody.scrollHeight;
2139
+ const availW = 702; // .pp-body content width: 794 - 46*2
2140
+ const availH = Math.max(160, BODY_BUDGET - usedH - 60);
2141
+ const scale = Math.min(availW / fullPage.w, availH / fullPage.h, 1);
2142
+ const dispW = Math.max(1, Math.round(fullPage.w * scale));
2143
+ const dispH = Math.max(1, Math.round(fullPage.h * scale));
2144
+ const shotCard = document.createElement('div');
2145
+ shotCard.className = 'pp-card';
2146
+ shotCard.innerHTML =
2147
+ '<div class="pp-shot-label"><span>Whole page, every screenshot stitched together</span></div>' +
2148
+ '<div class="pp-shot-frame" style="display:flex;justify-content:center;">' +
2149
+ '<img width="' + dispW + '" height="' + dispH + '" style="width:' + dispW + 'px;height:' + dispH + 'px;" src="' + fullPage.dataUrl + '" alt=""></div>';
2150
+ curBody.appendChild(shotCard);
2151
  } else {
2152
  const empty = document.createElement('div');
2153
  empty.className = 'pp-card';
 
2281
 
2282
  ctx.clearRect(0, 0, cssW, cssH);
2283
 
2284
+ ctx.strokeStyle = 'rgba(198,188,178,0.16)';
2285
+ ctx.fillStyle = '#8E8E92';
2286
  ctx.font = '9px ' + font;
2287
  ctx.textAlign = 'right';
2288
  ctx.textBaseline = 'middle';
 
2298
  const xFor = function(i){ return n > 1 ? padL + i * stepX : padL + plotW / 2; };
2299
  const yFor = function(v){ return padT + plotH - (v / top) * plotH; };
2300
 
2301
+ ctx.strokeStyle = '#FFE9A8';
2302
  ctx.lineWidth = 2;
2303
  ctx.beginPath();
2304
  items.forEach(function(r, i){
 
2310
  const showValues = n <= 10;
2311
  items.forEach(function(r, i){
2312
  const x = xFor(i), y = yFor(r.seconds);
2313
+ ctx.fillStyle = '#FFE9A8';
 
 
2314
  ctx.beginPath(); ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.fill();
2315
  if (showValues) {
2316
+ ctx.fillStyle = '#F9F8F3';
2317
  ctx.font = '600 9px ' + font;
2318
  ctx.textAlign = 'center';
2319
  ctx.textBaseline = 'alphabetic';
 
2321
  }
2322
  });
2323
 
2324
+ ctx.fillStyle = '#C6BCB2';
2325
  ctx.font = '8.5px ' + font;
2326
  ctx.textAlign = 'center';
2327
  ctx.textBaseline = 'top';
2328
  items.forEach(function(r, i){ ctx.fillText('#' + (i + 1), xFor(i), padT + plotH + 8); });
2329
 
2330
+ ctx.fillStyle = '#8E8E92';
2331
  ctx.font = '700 8px ' + font;
2332
  ctx.textAlign = 'center';
2333
  ctx.fillText('WEBPAGE ELEMENTS', padL + plotW / 2, cssH - 8);
 
2353
  segments = build_screenshot_segments(gaze, dom, session_dir)
2354
  summary = session_summary(gaze, dom)
2355
  mouse = summarize_mouse(session_dir)
2356
+ mouse_fullpage = build_mouse_fullpage_points(session_dir)
2357
+ page_dims = full_page_dims(dom)
2358
 
2359
  top_label = ranking[0]["label"] if ranking else "—"
2360
 
2361
  # session_dir's own folder name is already the session's timestamp
2362
  # (browser_session.py names it that way) — reused here only to print a
2363
+ # human-readable date in the header, same convention _sessions_json()
2364
+ # already parses for the session-history modal.
2365
  try:
2366
  session_ts = datetime.strptime(os.path.basename(session_dir.rstrip("/\\")), "%Y%m%d_%H%M%S")
2367
  session_timestamp = session_ts.strftime("%b %d, %Y — %I:%M %p").replace(" 0", " ")
2368
  except ValueError:
2369
  session_timestamp = "Unknown time"
2370
 
2371
+ # session_meta.json (browser_session.py's Api._write_session_meta())
2372
+ # records who was being tracked — absent on sessions recorded before
2373
+ # participants existed, which is fine, they're just "self" sessions
2374
+ # with nothing to label.
2375
+ subject_label = ""
2376
+ try:
2377
+ with open(os.path.join(session_dir, "session_meta.json"), "r", encoding="utf-8") as f:
2378
+ meta = json.load(f)
2379
+ if meta.get("tracking_mode") == "participant" and meta.get("participant_name"):
2380
+ subject_label = f"Tracking: {html_escape.escape(meta['participant_name'])} &nbsp;&middot;&nbsp; "
2381
+ except (OSError, json.JSONDecodeError):
2382
+ pass
2383
+
2384
  html = _TEMPLATE
2385
+ html = html.replace("__SUBJECT_LABEL__", subject_label)
2386
  html = html.replace("__URL__", summary["url"] or "Unknown page")
2387
  html = html.replace("__URL_JSON__", json.dumps(summary["url"] or ""))
2388
  html = html.replace("__SESSION_TIMESTAMP__", session_timestamp)
 
2397
  html = html.replace("__SEGMENTS_JSON__", json.dumps(segments))
2398
  html = html.replace("__MOUSE_INTERESTS_JSON__", json.dumps(mouse["interests"]))
2399
  html = html.replace("__MOUSE_CLICKS_JSON__", json.dumps(mouse["clicks"]))
2400
+ html = html.replace("__MOUSE_FULLPAGE_JSON__", json.dumps(mouse_fullpage))
2401
+ html = html.replace("__PAGE_DIMS_JSON__", json.dumps(page_dims))
2402
 
2403
  html = html.replace("__THEME_CSS__", theme.THEME_CSS)
2404
  html = html.replace("__THEME_JS__", theme.THEME_TOGGLE_JS)
auth.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ auth.py
3
+ Local, file-based multi-profile authentication for InsightUX.
4
+
5
+ InsightUX is a single-process desktop app with no server and no database —
6
+ this module IS the whole "backend": a JSON file of profiles (users.json,
7
+ one entry per person who has ever used this install) plus salted-hash
8
+ password verification. Plaintext passwords are never stored, logged, or
9
+ returned to the caller.
10
+
11
+ No heavy imports (stdlib only), so this can be imported cheaply from
12
+ browser_session.py, calibrate.py, and validate.py alike — same reasoning
13
+ theme.py already documents for its own "no cv2/mediapipe" import.
14
+ """
15
+
16
+ import os
17
+ import json
18
+ import uuid
19
+ import hmac
20
+ import hashlib
21
+ import secrets
22
+ from datetime import datetime, timezone
23
+
24
+ PBKDF2_ITERATIONS = 200_000
25
+ _HASH_NAME = "sha256"
26
+
27
+
28
+ class AuthError(Exception):
29
+ """Raised for user-facing auth failures (bad password, duplicate
30
+ email, missing fields, ...) — callers show str(e) directly as the
31
+ status message, same pattern as the rest of the app's _set_status calls."""
32
+
33
+
34
+ def _hash_password(password, salt=None):
35
+ """Returns (salt_hex, hash_hex). A fresh random salt is generated
36
+ unless one is supplied (re-hashing a login attempt for comparison)."""
37
+ if salt is None:
38
+ salt = secrets.token_bytes(16)
39
+ elif isinstance(salt, str):
40
+ salt = bytes.fromhex(salt)
41
+ digest = hashlib.pbkdf2_hmac(_HASH_NAME, password.encode("utf-8"), salt, PBKDF2_ITERATIONS)
42
+ return salt.hex(), digest.hex()
43
+
44
+
45
+ def verify_password(password, salt_hex, hash_hex):
46
+ _, candidate_hex = _hash_password(password, salt_hex)
47
+ return hmac.compare_digest(candidate_hex, hash_hex)
48
+
49
+
50
+ def _users_path(data_dir):
51
+ return os.path.join(data_dir, "users.json")
52
+
53
+
54
+ def load_users(data_dir):
55
+ """{user_id: {...}} — empty dict if the file doesn't exist yet (first
56
+ run) or is unreadable, never raises."""
57
+ path = _users_path(data_dir)
58
+ if not os.path.exists(path):
59
+ return {}
60
+ try:
61
+ with open(path, "r", encoding="utf-8") as f:
62
+ return json.load(f)
63
+ except (json.JSONDecodeError, OSError):
64
+ return {}
65
+
66
+
67
+ def save_users(data_dir, users):
68
+ os.makedirs(data_dir, exist_ok=True)
69
+ path = _users_path(data_dir)
70
+ tmp = path + ".tmp"
71
+ with open(tmp, "w", encoding="utf-8") as f:
72
+ json.dump(users, f, indent=2)
73
+ os.replace(tmp, path) # atomic on POSIX and Windows -- never leaves users.json half-written
74
+
75
+
76
+ def _avatar_initial(name):
77
+ name = (name or "").strip()
78
+ return (name[0] if name else "?").upper()
79
+
80
+
81
+ def public_profile(record):
82
+ """Strip salt/hash before this ever reaches JS -- every function that
83
+ hands profile data back to the frontend routes through this."""
84
+ return {
85
+ "id": record["id"],
86
+ "name": record["name"],
87
+ "email": record["email"],
88
+ "avatar": record.get("avatar") or _avatar_initial(record.get("name")),
89
+ "created_at": record.get("created_at"),
90
+ }
91
+
92
+
93
+ def create_user(data_dir, name, email, password):
94
+ name = (name or "").strip()
95
+ email = (email or "").strip().lower()
96
+ if not name:
97
+ raise AuthError("Name is required.")
98
+ if not email or "@" not in email:
99
+ raise AuthError("A valid email is required.")
100
+ if not password or len(password) < 6:
101
+ raise AuthError("Password must be at least 6 characters.")
102
+
103
+ users = load_users(data_dir)
104
+ for existing in users.values():
105
+ if existing.get("email", "").lower() == email:
106
+ raise AuthError("A profile with that email already exists.")
107
+
108
+ user_id = uuid.uuid4().hex[:12]
109
+ salt_hex, hash_hex = _hash_password(password)
110
+ record = {
111
+ "id": user_id,
112
+ "name": name,
113
+ "email": email,
114
+ "salt": salt_hex,
115
+ "hash": hash_hex,
116
+ "avatar": _avatar_initial(name),
117
+ "created_at": datetime.now(timezone.utc).isoformat(),
118
+ }
119
+ users[user_id] = record
120
+ save_users(data_dir, users)
121
+ return public_profile(record)
122
+
123
+
124
+ def verify_login(data_dir, user_id, password):
125
+ """Returns the public profile dict on success, raises AuthError
126
+ (never a KeyError/generic exception) on any failure — bad id, missing
127
+ password, wrong password all look the same to the caller."""
128
+ users = load_users(data_dir)
129
+ record = users.get(user_id)
130
+ if not record or not password or not verify_password(password, record["salt"], record["hash"]):
131
+ raise AuthError("Incorrect email/profile or password.")
132
+ return public_profile(record)
133
+
134
+
135
+ def list_profiles(data_dir):
136
+ """Public profile list for the login/switch-profile picker -- never
137
+ includes salt/hash. Sorted by name for a stable, predictable picker."""
138
+ users = load_users(data_dir)
139
+ profiles = [public_profile(u) for u in users.values()]
140
+ profiles.sort(key=lambda p: p["name"].lower())
141
+ return profiles
142
+
143
+
144
+ def user_dir(data_dir, user_id):
145
+ """Every per-user file (sessions/, calibration.pkl, the fine-tuned
146
+ ONNX) lives under this one root — the single place that maps a user
147
+ id to a filesystem path."""
148
+ return os.path.join(data_dir, "users", user_id)
browser_session.py CHANGED
The diff for this file is too large to render. See raw diff
 
calibrate.py CHANGED
@@ -30,6 +30,17 @@ else:
30
  RESOURCE_DIR = os.path.dirname(os.path.abspath(__file__))
31
  DATA_DIR = RESOURCE_DIR
32
 
 
 
 
 
 
 
 
 
 
 
 
33
  from preprocessing.preprocessing_pipeline import (
34
  create_face_mesh,
35
  estimate_camera_matrix,
@@ -445,6 +456,26 @@ def filter_unreliable_points(gaze_vectors, screen_points, head_pitches_deg,
445
  ONNX_PATH = os.path.join(RESOURCE_DIR, "models", "gaze_cnn_v4.onnx")
446
  CKPT_PATH = os.path.join(RESOURCE_DIR, "checkpoints", "best_model_v4.pt")
447
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  # --- Fine-tune target geometry -------------------------------------------
449
  # USE_MEASURED_GEOMETRY=False keeps the original hand-picked constants
450
  # (k_h=0.6, k_v=0.4). Those are geometrically wrong in the abstract, but the
@@ -508,7 +539,7 @@ def main():
508
  # MAIN CALIBRATION
509
  # =============================================================================
510
 
511
- pipeline = InsightUXPipeline(ONNX_PATH)
512
  face_mesh = create_face_mesh(static_image_mode=False)
513
  cap = cv2.VideoCapture(0)
514
 
@@ -807,7 +838,7 @@ def main():
807
  screen_w = SCREEN_W,
808
  screen_h = SCREEN_H,
809
  ckpt_path = CKPT_PATH,
810
- out_onnx_path = ONNX_PATH,
811
  steps = 200,
812
  lr = 1e-4,
813
  k_h = K_H,
@@ -832,7 +863,7 @@ def main():
832
  # makes edges and corners unstable.
833
  # =============================================================================
834
  print("\n--- Fitting RBF calibration ---")
835
- pipeline = InsightUXPipeline(ONNX_PATH)
836
 
837
  adapted_gaze_vectors = []
838
  adapted_dispersion = []
 
30
  RESOURCE_DIR = os.path.dirname(os.path.abspath(__file__))
31
  DATA_DIR = RESOURCE_DIR
32
 
33
+ # browser_session.py's start_calibration() sets this env var to the active
34
+ # InsightUX profile's own folder before launching this file as a subprocess,
35
+ # so calibration.pkl/baseline_pose.pkl land there instead of the global
36
+ # DATA_DIR above — keeping one user's calibration from overwriting another's.
37
+ # Unset (e.g. running `python calibrate.py` directly for development) means
38
+ # exactly today's behavior: writes go to the global DATA_DIR.
39
+ _USER_DATA_DIR = os.environ.get("INSIGHTUX_USER_DATA_DIR")
40
+ if _USER_DATA_DIR:
41
+ DATA_DIR = _USER_DATA_DIR
42
+ os.makedirs(DATA_DIR, exist_ok=True)
43
+
44
  from preprocessing.preprocessing_pipeline import (
45
  create_face_mesh,
46
  estimate_camera_matrix,
 
456
  ONNX_PATH = os.path.join(RESOURCE_DIR, "models", "gaze_cnn_v4.onnx")
457
  CKPT_PATH = os.path.join(RESOURCE_DIR, "checkpoints", "best_model_v4.pt")
458
 
459
+ # Fine-tuning (below) used to save its adapted model back over ONNX_PATH
460
+ # itself — i.e. it overwrote the one bundled model every profile's live
461
+ # inference loads, so one person calibrating silently changed what every
462
+ # other profile's eye-tracking used. USER_ONNX_OUT_PATH is that same
463
+ # browser_session.py-supplied env var as DATA_DIR above: when set, the
464
+ # fine-tuned model is saved per-profile instead. Unset (standalone/dev
465
+ # runs) reproduces the exact previous behavior — out_onnx_path=ONNX_PATH.
466
+ USER_ONNX_OUT_PATH = os.environ.get("INSIGHTUX_USER_ONNX_OUT") or ONNX_PATH
467
+
468
+
469
+ def _current_onnx_path():
470
+ """Prefer this profile's own previously fine-tuned model, if a prior
471
+ calibration run produced one, over the stock bundled model — so the
472
+ live preview during point collection matches what real tracking will
473
+ actually use. No-op (always ONNX_PATH) when USER_ONNX_OUT_PATH isn't
474
+ set to a distinct per-user path, i.e. standalone/dev runs."""
475
+ if USER_ONNX_OUT_PATH != ONNX_PATH and os.path.exists(USER_ONNX_OUT_PATH):
476
+ return USER_ONNX_OUT_PATH
477
+ return ONNX_PATH
478
+
479
  # --- Fine-tune target geometry -------------------------------------------
480
  # USE_MEASURED_GEOMETRY=False keeps the original hand-picked constants
481
  # (k_h=0.6, k_v=0.4). Those are geometrically wrong in the abstract, but the
 
539
  # MAIN CALIBRATION
540
  # =============================================================================
541
 
542
+ pipeline = InsightUXPipeline(_current_onnx_path())
543
  face_mesh = create_face_mesh(static_image_mode=False)
544
  cap = cv2.VideoCapture(0)
545
 
 
838
  screen_w = SCREEN_W,
839
  screen_h = SCREEN_H,
840
  ckpt_path = CKPT_PATH,
841
+ out_onnx_path = USER_ONNX_OUT_PATH,
842
  steps = 200,
843
  lr = 1e-4,
844
  k_h = K_H,
 
863
  # makes edges and corners unstable.
864
  # =============================================================================
865
  print("\n--- Fitting RBF calibration ---")
866
+ pipeline = InsightUXPipeline(USER_ONNX_OUT_PATH if finetuned else _current_onnx_path())
867
 
868
  adapted_gaze_vectors = []
869
  adapted_dispersion = []
models/gaze_cnn_v4.onnx CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:f3564b61d6e1ee304a5f56722ae4963b364429924b224581a8e15ded051c0e16
3
  size 17542588
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6c4bdad4b3b438871bf7d3e884e08433069528a8c97dfa4975d1654ea4352c02
3
  size 17542588
participants.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ participants.py
3
+ Local participant records for InsightUX's multi-participant tracking.
4
+
5
+ A participant is NOT an InsightUX login/profile (see auth.py) — it's a
6
+ tracking SUBJECT that an owner creates to keep another person's sessions
7
+ separate from their own, e.g. a UX researcher running sessions on Rahul,
8
+ Priya, Aman. Participants never authenticate and always live nested inside
9
+ their owner's own folder (users/<owner_id>/participants/<id>/), not as a
10
+ sibling top-level directory — so a participant's data cannot structurally
11
+ exist outside their owner's folder at all.
12
+
13
+ One profile.json per participant folder, not a shared index file — a
14
+ participant folder is always a complete, self-contained, movable/deletable
15
+ unit, the same way users/<owner_id>/ already is relative to DATA_DIR.
16
+
17
+ No heavy imports (stdlib only) — same reasoning as theme.py/auth.py.
18
+ """
19
+
20
+ import os
21
+ import json
22
+ import shutil
23
+ import uuid
24
+ from datetime import datetime, timezone
25
+
26
+
27
+ class ParticipantError(Exception):
28
+ """User-facing failures (missing name, not found, ...)."""
29
+
30
+
31
+ def participant_dir(owner_dir, participant_id):
32
+ return os.path.join(owner_dir, "participants", participant_id)
33
+
34
+
35
+ def _profile_path(owner_dir, participant_id):
36
+ return os.path.join(participant_dir(owner_dir, participant_id), "profile.json")
37
+
38
+
39
+ def _save_profile(owner_dir, participant_id, record):
40
+ path = _profile_path(owner_dir, participant_id)
41
+ os.makedirs(os.path.dirname(path), exist_ok=True)
42
+ tmp = path + ".tmp"
43
+ with open(tmp, "w", encoding="utf-8") as f:
44
+ json.dump(record, f, indent=2)
45
+ os.replace(tmp, path) # atomic on POSIX and Windows
46
+
47
+
48
+ def create_participant(owner_dir, name, notes=""):
49
+ name = (name or "").strip()
50
+ if not name:
51
+ raise ParticipantError("Name is required.")
52
+ participant_id = uuid.uuid4().hex[:12]
53
+ record = {
54
+ "id": participant_id,
55
+ "name": name,
56
+ "notes": (notes or "").strip(),
57
+ "created_at": datetime.now(timezone.utc).isoformat(),
58
+ "session_count": 0,
59
+ "last_session_at": None,
60
+ }
61
+ _save_profile(owner_dir, participant_id, record)
62
+ return record
63
+
64
+
65
+ def get_participant(owner_dir, participant_id):
66
+ if not participant_id:
67
+ return None
68
+ path = _profile_path(owner_dir, participant_id)
69
+ if not os.path.exists(path):
70
+ return None
71
+ try:
72
+ with open(path, "r", encoding="utf-8") as f:
73
+ return json.load(f)
74
+ except (json.JSONDecodeError, OSError):
75
+ return None
76
+
77
+
78
+ def list_participants(owner_dir):
79
+ """Sorted by name — same predictable-picker convention as
80
+ auth.list_profiles()."""
81
+ root = os.path.join(owner_dir, "participants")
82
+ out = []
83
+ if os.path.isdir(root):
84
+ for name in os.listdir(root):
85
+ if not os.path.isdir(os.path.join(root, name)):
86
+ continue
87
+ record = get_participant(owner_dir, name)
88
+ if record:
89
+ out.append(record)
90
+ out.sort(key=lambda p: p["name"].lower())
91
+ return out
92
+
93
+
94
+ def update_participant(owner_dir, participant_id, name=None, notes=None):
95
+ """In-place edit — name/notes are only touched when explicitly passed
96
+ (None means "leave as-is"), so a caller updating just one field can't
97
+ accidentally blank out the other. id/created_at/session_count/
98
+ last_session_at are never touched here; renaming a participant must
99
+ never look like a new one to anything reading session_count."""
100
+ record = get_participant(owner_dir, participant_id)
101
+ if not record:
102
+ raise ParticipantError("That participant no longer exists.")
103
+ if name is not None:
104
+ name = name.strip()
105
+ if not name:
106
+ raise ParticipantError("Name is required.")
107
+ record["name"] = name
108
+ if notes is not None:
109
+ record["notes"] = notes.strip()
110
+ _save_profile(owner_dir, participant_id, record)
111
+ return record
112
+
113
+
114
+ def delete_participant(owner_dir, participant_id):
115
+ """Removes the participant's entire folder — profile.json, sessions/,
116
+ calibration.pkl, everything nested under it. Irreversible; the caller
117
+ (Api.delete_participant()) is expected to have already confirmed with
118
+ the user and to have refused this while that participant is the
119
+ active tracking subject mid-session."""
120
+ if not participant_id:
121
+ raise ParticipantError("No participant specified.")
122
+ pdir = participant_dir(owner_dir, participant_id)
123
+ if not os.path.isdir(pdir):
124
+ raise ParticipantError("That participant no longer exists.")
125
+ shutil.rmtree(pdir)
126
+
127
+
128
+ def touch_session_stats(owner_dir, participant_id):
129
+ """Called once a session for this participant actually starts — keeps
130
+ session_count/last_session_at current without needing to re-scan every
131
+ session folder just to show them in the picker/details view later."""
132
+ record = get_participant(owner_dir, participant_id)
133
+ if not record:
134
+ return
135
+ record["session_count"] = record.get("session_count", 0) + 1
136
+ record["last_session_at"] = datetime.now(timezone.utc).isoformat()
137
+ _save_profile(owner_dir, participant_id, record)
requirements-dev.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # InsightUX — dev/test-only dependencies. NOT needed to run the app itself
3
+ # (see requirements.txt for that) — only to run `pytest` against tests/.
4
+ #
5
+ # Install with:
6
+ # pip install -r requirements-dev.txt
7
+ # =============================================================================
8
+
9
+ pytest==9.1.1
tests/conftest.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+
4
+ # auth.py/participants.py/analysis.py live at the project root, not in a
5
+ # package -- this is the one place that needs to know that, instead of
6
+ # every test file repeating its own sys.path hack.
7
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
tests/test_analysis.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for analysis.py's pure computation — session loading, AOI
3
+ attribution, dwell ranking, mouse summarization. No cv2/mediapipe needed
4
+ (analysis.py itself only imports stdlib + theme).
5
+ """
6
+
7
+ import os
8
+ import json
9
+
10
+ import analysis
11
+
12
+
13
+ # --- _load_jsonl / load_session -------------------------------------------
14
+
15
+ def test_load_jsonl_missing_file_returns_empty(tmp_path):
16
+ assert analysis._load_jsonl(str(tmp_path / "nope.jsonl")) == []
17
+
18
+
19
+ def test_load_jsonl_skips_malformed_lines(tmp_path):
20
+ path = tmp_path / "log.jsonl"
21
+ path.write_text('{"a": 1}\nnot json\n{"b": 2}\n\n', encoding="utf-8")
22
+ records = analysis._load_jsonl(str(path))
23
+ assert records == [{"a": 1}, {"b": 2}]
24
+
25
+
26
+ def _write_jsonl(path, records):
27
+ with open(path, "w", encoding="utf-8") as f:
28
+ for r in records:
29
+ f.write(json.dumps(r) + "\n")
30
+
31
+
32
+ def test_load_session_filters_by_type_and_sorts_by_time(tmp_path):
33
+ session_dir = tmp_path / "20260101_120000"
34
+ session_dir.mkdir()
35
+ _write_jsonl(session_dir / "gaze_log.jsonl", [
36
+ {"type": "gaze", "t": 2.0, "sx": 10, "sy": 10},
37
+ {"type": "other", "t": 0.5},
38
+ {"type": "gaze", "t": 1.0, "sx": 5, "sy": 5},
39
+ ])
40
+ _write_jsonl(session_dir / "dom_log.jsonl", [
41
+ {"type": "dom", "t": 1.5, "url": "https://example.com", "aois": []},
42
+ ])
43
+ gaze, dom = analysis.load_session(str(session_dir))
44
+ assert [g["t"] for g in gaze] == [1.0, 2.0] # sorted, "other" filtered out
45
+ assert len(dom) == 1
46
+ assert dom[0]["url"] == "https://example.com"
47
+
48
+
49
+ def test_load_session_handles_missing_files(tmp_path):
50
+ session_dir = tmp_path / "20260101_120000"
51
+ session_dir.mkdir()
52
+ gaze, dom = analysis.load_session(str(session_dir))
53
+ assert gaze == []
54
+ assert dom == []
55
+
56
+
57
+ # --- session_summary --------------------------------------------------------
58
+
59
+ def test_session_summary_empty_gaze():
60
+ summary = analysis.session_summary([], [])
61
+ assert summary == {"duration": 0.0, "url": None, "samples": 0}
62
+
63
+
64
+ def test_session_summary_computes_duration_and_url():
65
+ gaze = [{"t": 10.0}, {"t": 12.5}, {"t": 15.0}]
66
+ dom = [{"t": 10.0, "url": "https://a.com"}, {"t": 14.0, "url": "https://b.com"}]
67
+ summary = analysis.session_summary(gaze, dom)
68
+ assert summary["duration"] == 5.0
69
+ assert summary["url"] == "https://b.com" # most recent dom snapshot
70
+ assert summary["samples"] == 3
71
+
72
+
73
+ def test_session_summary_url_none_without_dom():
74
+ summary = analysis.session_summary([{"t": 0.0}, {"t": 1.0}], [])
75
+ assert summary["url"] is None
76
+
77
+
78
+ # --- friendly_label -----------------------------------------------------
79
+
80
+ def test_friendly_label_handles_none_and_empty():
81
+ assert analysis.friendly_label(None) == "Unlabeled area"
82
+ assert analysis.friendly_label("") == "Unlabeled area"
83
+
84
+
85
+ def test_friendly_label_known_landmarks():
86
+ assert analysis.friendly_label("navbar") == "Navigation bar"
87
+ assert analysis.friendly_label("header") == "Page header"
88
+ assert analysis.friendly_label("footer") == "Page footer"
89
+ assert analysis.friendly_label("video") == "Video"
90
+
91
+
92
+ def test_friendly_label_image():
93
+ assert analysis.friendly_label("img: logo.png") == "Image — logo.png"
94
+
95
+
96
+ def test_friendly_label_headings():
97
+ assert analysis.friendly_label("h1: Welcome") == 'Main heading — “Welcome”'
98
+ assert analysis.friendly_label("h2: About") == 'Heading — “About”'
99
+ assert analysis.friendly_label("h3: Details") == 'Sub-heading — “Details”'
100
+
101
+
102
+ def test_friendly_label_paragraph():
103
+ assert analysis.friendly_label("p (some text)") == 'Text — “some text…”'
104
+
105
+
106
+ def test_friendly_label_id_and_class_selectors():
107
+ assert analysis.friendly_label("#hero") == "Section: hero"
108
+ assert analysis.friendly_label(".card") == "Block: card"
109
+
110
+
111
+ def test_friendly_label_falls_back_to_capitalized_raw():
112
+ assert analysis.friendly_label("button") == "Button"
113
+
114
+
115
+ # --- _find_aoi / attribute_gaze ----------------------------------------
116
+
117
+ def _aoi(label, x, y, w, h):
118
+ return {"label": label, "x": x, "y": y, "w": w, "h": h}
119
+
120
+
121
+ def test_find_aoi_returns_none_when_no_match():
122
+ aois = [_aoi("header", 0, 0, 100, 50)]
123
+ assert analysis._find_aoi(500, 500, aois) is None
124
+
125
+
126
+ def test_find_aoi_matches_point_inside_box():
127
+ aois = [_aoi("header", 0, 0, 100, 50)]
128
+ assert analysis._find_aoi(50, 25, aois) == "header"
129
+
130
+
131
+ def test_find_aoi_respects_padding():
132
+ aois = [_aoi("header", 100, 100, 50, 50)]
133
+ # Just outside the box but within PAD_PX (90) of it
134
+ assert analysis._find_aoi(95, 125, aois) == "header"
135
+ # Far outside the padded box entirely
136
+ assert analysis._find_aoi(1000, 1000, aois) is None
137
+
138
+
139
+ def test_find_aoi_picks_smallest_area_on_overlap():
140
+ aois = [
141
+ _aoi("big", 0, 0, 500, 500),
142
+ _aoi("small", 100, 100, 20, 20),
143
+ ]
144
+ assert analysis._find_aoi(110, 110, aois) == "small"
145
+
146
+
147
+ def test_find_aoi_skips_embed_label():
148
+ aois = [_aoi("embed", 0, 0, 1000, 1000)]
149
+ assert analysis._find_aoi(500, 500, aois) is None
150
+
151
+
152
+ def test_attribute_gaze_all_none_without_dom():
153
+ gaze = [{"t": 1.0, "sx": 5, "sy": 5}, {"t": 2.0, "sx": 6, "sy": 6}]
154
+ result = analysis.attribute_gaze(gaze, [])
155
+ assert result == [(1.0, None), (2.0, None)]
156
+
157
+
158
+ def test_attribute_gaze_uses_most_recent_dom_snapshot():
159
+ gaze = [{"t": 5.0, "sx": 50, "sy": 25}]
160
+ dom = [
161
+ {"t": 1.0, "aois": [_aoi("old", 0, 0, 10, 10)]},
162
+ {"t": 4.0, "aois": [_aoi("header", 0, 0, 100, 50)]},
163
+ ]
164
+ result = analysis.attribute_gaze(gaze, dom)
165
+ assert result == [(5.0, "header")]
166
+
167
+
168
+ def test_attribute_gaze_before_first_dom_snapshot_is_none():
169
+ gaze = [{"t": 0.5, "sx": 50, "sy": 25}]
170
+ dom = [{"t": 4.0, "aois": [_aoi("header", 0, 0, 100, 50)]}]
171
+ result = analysis.attribute_gaze(gaze, dom)
172
+ assert result == [(0.5, None)]
173
+
174
+
175
+ # --- compute_dwell_ranking -----------------------------------------------
176
+
177
+ def test_compute_dwell_ranking_empty_for_fewer_than_two_points():
178
+ assert analysis.compute_dwell_ranking([]) == []
179
+ assert analysis.compute_dwell_ranking([(0.0, "header")]) == []
180
+
181
+
182
+ def test_compute_dwell_ranking_accumulates_time_per_label():
183
+ attributed = [
184
+ (0.0, "header"), (1.0, "header"), (2.0, "footer"), (3.0, "footer"), (4.0, None),
185
+ ]
186
+ ranking = analysis.compute_dwell_ranking(attributed)
187
+ labels = {r["label"]: r for r in ranking}
188
+ assert "Page header" in labels
189
+ assert "Page footer" in labels
190
+ assert labels["Page header"]["seconds"] == 2.0 # (1.0-0.0) + (2.0-1.0)
191
+ assert labels["Page footer"]["seconds"] == 2.0 # (3.0-2.0) + (4.0-3.0)
192
+ assert labels["Page header"]["hits"] == 2
193
+
194
+
195
+ def test_compute_dwell_ranking_sorted_descending_by_seconds():
196
+ attributed = [
197
+ (0.0, "footer"), (1.0, "footer"), (2.0, "header"), (2.5, None),
198
+ ]
199
+ ranking = analysis.compute_dwell_ranking(attributed)
200
+ assert ranking[0]["label"] == "Page footer"
201
+ assert ranking[0]["seconds"] >= ranking[1]["seconds"]
202
+
203
+
204
+ def test_compute_dwell_ranking_pct_sums_to_roughly_100():
205
+ attributed = [(0.0, "header"), (1.0, "footer"), (2.0, None)]
206
+ ranking = analysis.compute_dwell_ranking(attributed)
207
+ assert abs(sum(r["pct"] for r in ranking) - 100.0) < 0.5
208
+
209
+
210
+ # --- summarize_mouse ---------------------------------------------------
211
+
212
+ def test_summarize_mouse_no_file(tmp_path):
213
+ summary = analysis.summarize_mouse(str(tmp_path))
214
+ assert summary["click_count"] == 0
215
+ assert summary["interests"] == []
216
+ assert summary["trail_points"] == 0
217
+ assert summary["heatmap_points"] == 0
218
+
219
+
220
+ def test_summarize_mouse_aggregates_dwell_and_clicks(tmp_path):
221
+ _write_jsonl(tmp_path / "mouse_log.jsonl", [
222
+ {"type": "mouse_batch", "dwell": [{"element": "header", "duration": 1000}],
223
+ "click": [{"timestamp": "2026-01-01T00:00:01"}], "trail": [1, 2], "heatmap": [1]},
224
+ {"type": "mouse_batch", "dwell": [{"element": "header", "duration": 500}],
225
+ "click": [{"timestamp": "2026-01-01T00:00:00"}], "trail": [1], "heatmap": []},
226
+ ])
227
+ summary = analysis.summarize_mouse(str(tmp_path))
228
+ assert summary["click_count"] == 2
229
+ assert summary["trail_points"] == 3
230
+ assert summary["heatmap_points"] == 1
231
+ assert summary["interests"][0]["element"] == "header"
232
+ assert summary["interests"][0]["seconds"] == 1.5 # (1000+500)ms -> 1.5s
233
+ # clicks sorted ascending by timestamp
234
+ assert summary["clicks"][0]["timestamp"] == "2026-01-01T00:00:00"
235
+
236
+
237
+ def test_summarize_mouse_caps_clicks_at_fifty(tmp_path):
238
+ clicks = [{"timestamp": f"2026-01-01T00:{i:02d}:00"} for i in range(60)]
239
+ _write_jsonl(tmp_path / "mouse_log.jsonl", [
240
+ {"type": "mouse_batch", "dwell": [], "click": clicks, "trail": [], "heatmap": []},
241
+ ])
242
+ summary = analysis.summarize_mouse(str(tmp_path))
243
+ assert summary["click_count"] == 60
244
+ assert len(summary["clicks"]) == 50
tests/test_auth.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for auth.py — pure/stdlib-only, no camera or webview needed.
3
+ Every test gets its own tmp_path as data_dir, so nothing here touches the
4
+ real users.json on disk.
5
+ """
6
+
7
+ import os
8
+
9
+ import pytest
10
+
11
+ import auth
12
+
13
+
14
+ # --- password hashing -------------------------------------------------------
15
+
16
+ def test_hash_password_generates_random_salt():
17
+ salt1, hash1 = auth._hash_password("hunter2")
18
+ salt2, hash2 = auth._hash_password("hunter2")
19
+ assert salt1 != salt2
20
+ assert hash1 != hash2 # different salt -> different hash for the same password
21
+
22
+
23
+ def test_hash_password_reproducible_with_given_salt():
24
+ salt, hash1 = auth._hash_password("hunter2")
25
+ _, hash2 = auth._hash_password("hunter2", salt=salt)
26
+ assert hash1 == hash2
27
+
28
+
29
+ def test_verify_password_accepts_correct_password():
30
+ salt, hash_hex = auth._hash_password("correct horse battery staple")
31
+ assert auth.verify_password("correct horse battery staple", salt, hash_hex) is True
32
+
33
+
34
+ def test_verify_password_rejects_wrong_password():
35
+ salt, hash_hex = auth._hash_password("correct horse battery staple")
36
+ assert auth.verify_password("wrong password", salt, hash_hex) is False
37
+
38
+
39
+ # --- create_user validation --------------------------------------------------
40
+
41
+ def test_create_user_rejects_empty_name(tmp_path):
42
+ with pytest.raises(auth.AuthError):
43
+ auth.create_user(str(tmp_path), "", "a@b.com", "password123")
44
+
45
+
46
+ def test_create_user_rejects_invalid_email(tmp_path):
47
+ with pytest.raises(auth.AuthError):
48
+ auth.create_user(str(tmp_path), "Aman", "not-an-email", "password123")
49
+
50
+
51
+ def test_create_user_rejects_short_password(tmp_path):
52
+ with pytest.raises(auth.AuthError):
53
+ auth.create_user(str(tmp_path), "Aman", "a@b.com", "short")
54
+
55
+
56
+ def test_create_user_rejects_duplicate_email_case_insensitive(tmp_path):
57
+ auth.create_user(str(tmp_path), "Aman", "Aman@Example.com", "password123")
58
+ with pytest.raises(auth.AuthError):
59
+ auth.create_user(str(tmp_path), "Someone Else", "aman@example.com", "different123")
60
+
61
+
62
+ def test_create_user_returns_public_profile_without_secrets(tmp_path):
63
+ profile = auth.create_user(str(tmp_path), "Aman", "aman@example.com", "password123")
64
+ assert profile["name"] == "Aman"
65
+ assert profile["email"] == "aman@example.com"
66
+ assert "salt" not in profile
67
+ assert "hash" not in profile
68
+ assert "id" in profile
69
+ assert profile["avatar"] == "A"
70
+
71
+
72
+ def test_create_user_persists_across_loads(tmp_path):
73
+ auth.create_user(str(tmp_path), "Aman", "aman@example.com", "password123")
74
+ users = auth.load_users(str(tmp_path))
75
+ assert len(users) == 1
76
+ record = next(iter(users.values()))
77
+ assert record["email"] == "aman@example.com"
78
+ assert "hash" in record # stored on disk, just never returned to callers
79
+
80
+
81
+ # --- login --------------------------------------------------------------
82
+
83
+ def test_verify_login_succeeds_with_correct_password(tmp_path):
84
+ created = auth.create_user(str(tmp_path), "Aman", "aman@example.com", "password123")
85
+ profile = auth.verify_login(str(tmp_path), created["id"], "password123")
86
+ assert profile["id"] == created["id"]
87
+
88
+
89
+ def test_verify_login_fails_with_wrong_password(tmp_path):
90
+ created = auth.create_user(str(tmp_path), "Aman", "aman@example.com", "password123")
91
+ with pytest.raises(auth.AuthError):
92
+ auth.verify_login(str(tmp_path), created["id"], "wrong-password")
93
+
94
+
95
+ def test_verify_login_fails_for_unknown_user_id(tmp_path):
96
+ with pytest.raises(auth.AuthError):
97
+ auth.verify_login(str(tmp_path), "does-not-exist", "whatever")
98
+
99
+
100
+ def test_verify_login_fails_for_empty_password(tmp_path):
101
+ created = auth.create_user(str(tmp_path), "Aman", "aman@example.com", "password123")
102
+ with pytest.raises(auth.AuthError):
103
+ auth.verify_login(str(tmp_path), created["id"], "")
104
+
105
+
106
+ # --- list_profiles / user_dir ------------------------------------------------
107
+
108
+ def test_list_profiles_sorted_by_name_and_excludes_secrets(tmp_path):
109
+ auth.create_user(str(tmp_path), "Zara", "zara@example.com", "password123")
110
+ auth.create_user(str(tmp_path), "Aman", "aman@example.com", "password123")
111
+ profiles = auth.list_profiles(str(tmp_path))
112
+ assert [p["name"] for p in profiles] == ["Aman", "Zara"]
113
+ assert all("salt" not in p and "hash" not in p for p in profiles)
114
+
115
+
116
+ def test_list_profiles_empty_when_no_users_file(tmp_path):
117
+ assert auth.list_profiles(str(tmp_path)) == []
118
+
119
+
120
+ def test_user_dir_is_nested_under_users(tmp_path):
121
+ path = auth.user_dir(str(tmp_path), "abc123")
122
+ assert path == os.path.join(str(tmp_path), "users", "abc123")
tests/test_participants.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for participants.py — pure/stdlib-only, no camera or webview
3
+ needed. Every test gets its own tmp_path as owner_dir.
4
+ """
5
+
6
+ import os
7
+
8
+ import pytest
9
+
10
+ import participants
11
+
12
+
13
+ # --- create_participant -------------------------------------------------
14
+
15
+ def test_create_participant_rejects_empty_name(tmp_path):
16
+ with pytest.raises(participants.ParticipantError):
17
+ participants.create_participant(str(tmp_path), " ")
18
+
19
+
20
+ def test_create_participant_returns_expected_fields(tmp_path):
21
+ record = participants.create_participant(str(tmp_path), "Rahul", "left-handed")
22
+ assert record["name"] == "Rahul"
23
+ assert record["notes"] == "left-handed"
24
+ assert record["session_count"] == 0
25
+ assert record["last_session_at"] is None
26
+ assert "id" in record and "created_at" in record
27
+
28
+
29
+ def test_create_participant_strips_whitespace(tmp_path):
30
+ record = participants.create_participant(str(tmp_path), " Rahul ", " notes ")
31
+ assert record["name"] == "Rahul"
32
+ assert record["notes"] == "notes"
33
+
34
+
35
+ def test_create_participant_persists_to_disk(tmp_path):
36
+ record = participants.create_participant(str(tmp_path), "Rahul")
37
+ profile_path = os.path.join(str(tmp_path), "participants", record["id"], "profile.json")
38
+ assert os.path.exists(profile_path)
39
+
40
+
41
+ # --- get_participant / list_participants ---------------------------------
42
+
43
+ def test_get_participant_returns_none_for_missing(tmp_path):
44
+ assert participants.get_participant(str(tmp_path), "does-not-exist") is None
45
+
46
+
47
+ def test_get_participant_returns_none_for_falsy_id(tmp_path):
48
+ assert participants.get_participant(str(tmp_path), "") is None
49
+ assert participants.get_participant(str(tmp_path), None) is None
50
+
51
+
52
+ def test_get_participant_roundtrips(tmp_path):
53
+ created = participants.create_participant(str(tmp_path), "Rahul")
54
+ fetched = participants.get_participant(str(tmp_path), created["id"])
55
+ assert fetched == created
56
+
57
+
58
+ def test_list_participants_sorted_by_name(tmp_path):
59
+ participants.create_participant(str(tmp_path), "Zara")
60
+ participants.create_participant(str(tmp_path), "Aman")
61
+ names = [p["name"] for p in participants.list_participants(str(tmp_path))]
62
+ assert names == ["Aman", "Zara"]
63
+
64
+
65
+ def test_list_participants_empty_when_none_created(tmp_path):
66
+ assert participants.list_participants(str(tmp_path)) == []
67
+
68
+
69
+ # --- update_participant ---------------------------------------------------
70
+
71
+ def test_update_participant_changes_name_and_notes(tmp_path):
72
+ created = participants.create_participant(str(tmp_path), "Rahul", "old notes")
73
+ updated = participants.update_participant(str(tmp_path), created["id"], name="Rahul K", notes="new notes")
74
+ assert updated["name"] == "Rahul K"
75
+ assert updated["notes"] == "new notes"
76
+
77
+
78
+ def test_update_participant_leaves_untouched_field_when_none(tmp_path):
79
+ created = participants.create_participant(str(tmp_path), "Rahul", "keep me")
80
+ updated = participants.update_participant(str(tmp_path), created["id"], name="Rahul K", notes=None)
81
+ assert updated["name"] == "Rahul K"
82
+ assert updated["notes"] == "keep me"
83
+
84
+
85
+ def test_update_participant_rejects_empty_name(tmp_path):
86
+ created = participants.create_participant(str(tmp_path), "Rahul")
87
+ with pytest.raises(participants.ParticipantError):
88
+ participants.update_participant(str(tmp_path), created["id"], name=" ")
89
+
90
+
91
+ def test_update_participant_rejects_missing_participant(tmp_path):
92
+ with pytest.raises(participants.ParticipantError):
93
+ participants.update_participant(str(tmp_path), "does-not-exist", name="X")
94
+
95
+
96
+ def test_update_participant_preserves_session_stats(tmp_path):
97
+ created = participants.create_participant(str(tmp_path), "Rahul")
98
+ participants.touch_session_stats(str(tmp_path), created["id"])
99
+ updated = participants.update_participant(str(tmp_path), created["id"], name="Rahul K")
100
+ assert updated["session_count"] == 1
101
+
102
+
103
+ # --- delete_participant ---------------------------------------------------
104
+
105
+ def test_delete_participant_removes_folder(tmp_path):
106
+ created = participants.create_participant(str(tmp_path), "Rahul")
107
+ pdir = participants.participant_dir(str(tmp_path), created["id"])
108
+ assert os.path.isdir(pdir)
109
+ participants.delete_participant(str(tmp_path), created["id"])
110
+ assert not os.path.exists(pdir)
111
+ assert participants.get_participant(str(tmp_path), created["id"]) is None
112
+
113
+
114
+ def test_delete_participant_rejects_missing_participant(tmp_path):
115
+ with pytest.raises(participants.ParticipantError):
116
+ participants.delete_participant(str(tmp_path), "does-not-exist")
117
+
118
+
119
+ def test_delete_participant_rejects_falsy_id(tmp_path):
120
+ with pytest.raises(participants.ParticipantError):
121
+ participants.delete_participant(str(tmp_path), "")
122
+
123
+
124
+ # --- touch_session_stats ---------------------------------------------------
125
+
126
+ def test_touch_session_stats_increments_count_and_sets_timestamp(tmp_path):
127
+ created = participants.create_participant(str(tmp_path), "Rahul")
128
+ assert created["session_count"] == 0
129
+ participants.touch_session_stats(str(tmp_path), created["id"])
130
+ updated = participants.get_participant(str(tmp_path), created["id"])
131
+ assert updated["session_count"] == 1
132
+ assert updated["last_session_at"] is not None
133
+
134
+
135
+ def test_touch_session_stats_noop_for_missing_participant(tmp_path):
136
+ # Should not raise -- just silently does nothing, since the caller
137
+ # (browser_session.py's _write_session_meta) has already committed to
138
+ # the session by this point and shouldn't crash a session over it.
139
+ participants.touch_session_stats(str(tmp_path), "does-not-exist")
theme.py CHANGED
@@ -59,6 +59,11 @@ ICON_PATHS = {
59
  "menu": '<line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="18" y2="18"/>',
60
  "bar-chart": '<line x1="12" x2="12" y1="20" y2="10"/><line x1="18" x2="18" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="16"/>',
61
  "trending-up": '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
 
 
 
 
 
62
  }
63
 
64
 
@@ -92,51 +97,67 @@ ICONS_JS = (
92
 
93
  THEME_CSS = r"""
94
  :root {
95
- --iux-bg: #12131A;
96
- --iux-bg-alt: #171923;
97
- --iux-surface: #1E2030;
98
- --iux-surface-hi: #262940;
99
- --iux-border: rgba(148, 138, 179, 0.16);
100
- --iux-text: #F1EEFA;
101
- --iux-text-dim: #9B95B3;
102
- --iux-text-faint: #6E6885;
103
-
104
- --iux-primary: #7C3AED;
105
- --iux-primary-light: #8B5CF6;
106
- --iux-indigo: #6366F1;
107
- --iux-accent-grad: linear-gradient(135deg, #6366F1, #8B5CF6 55%, #C084FC);
108
- --iux-cyan: #67E8F9;
109
- --iux-lavender: #C4B5FD;
 
110
 
111
  --iux-success: #34D399;
112
- --iux-warning: #FBBF24;
113
  --iux-danger: #FB7185;
114
  --iux-info: #38BDF8;
115
 
116
  --iux-radius-sm: 8px;
117
  --iux-radius: 14px;
118
  --iux-radius-lg: 20px;
119
- --iux-shadow-sm: 0 2px 10px rgba(0,0,0,0.28);
120
- --iux-shadow: 0 10px 34px rgba(10,6,24,0.45);
121
- --iux-shadow-glow: 0 0 0 1px rgba(139,92,246,0.35), 0 8px 28px rgba(124,58,237,0.28);
122
  --iux-blur: blur(18px);
123
  --iux-ease: cubic-bezier(0.22, 1, 0.36, 1);
124
  --iux-font: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Manrope', Arial, sans-serif;
 
 
 
 
 
125
  }
126
 
127
  :root[data-theme="light"] {
128
- --iux-bg: #F7F5FB;
129
- --iux-bg-alt: #F1EEFA;
130
- --iux-surface: #FFFFFF;
131
- --iux-surface-hi: #F3F0FA;
132
- --iux-border: rgba(99, 79, 150, 0.14);
133
- --iux-text: #211D33;
134
- --iux-text-dim: #635C7D;
135
- --iux-text-faint: #918AA8;
136
-
137
- --iux-shadow-sm: 0 2px 10px rgba(76,60,120,0.08);
138
- --iux-shadow: 0 14px 34px rgba(76,60,120,0.14);
139
- --iux-shadow-glow: 0 0 0 1px rgba(124,58,237,0.18), 0 8px 24px rgba(124,58,237,0.16);
 
 
 
 
 
 
 
 
 
 
140
  }
141
 
142
  .iux-icon { display: inline-block; vertical-align: middle; flex-shrink: 0; }
@@ -237,7 +258,7 @@ THEME_TOGGLE_JS = r"""
237
  try { localStorage.setItem('__iux_prefs__', JSON.stringify(prefs)); } catch (e) {}
238
  }
239
  const prefs = readPrefs();
240
- const theme = prefs.theme === 'light' ? 'light' : 'dark';
241
  document.documentElement.setAttribute('data-theme', theme);
242
 
243
  window.insightuxGetPrefs = readPrefs;
@@ -246,7 +267,7 @@ THEME_TOGGLE_JS = r"""
246
  p[key] = value;
247
  writePrefs(p);
248
  };
249
- window.insightuxGetTheme = function(){ return readPrefs().theme === 'light' ? 'light' : 'dark'; };
250
  window.insightuxToggleTheme = function(){
251
  const next = window.insightuxGetTheme() === 'light' ? 'dark' : 'light';
252
  document.documentElement.setAttribute('data-theme', next);
 
59
  "menu": '<line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="18" y2="18"/>',
60
  "bar-chart": '<line x1="12" x2="12" y1="20" y2="10"/><line x1="18" x2="18" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="16"/>',
61
  "trending-up": '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
62
+ "user": '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
63
+ "users": '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
64
+ "log-out": '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
65
+ "more-vertical": '<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>',
66
+ "plus": '<path d="M5 12h14"/><path d="M12 5v14"/>',
67
  }
68
 
69
 
 
97
 
98
  THEME_CSS = r"""
99
  :root {
100
+ --iux-bg: #1B1918;
101
+ --iux-bg-alt: #221F1D;
102
+ --iux-surface: #292522;
103
+ --iux-surface-hi: #332E2A;
104
+ --iux-border: rgba(198, 188, 178, 0.16);
105
+ --iux-text: #F9F8F3;
106
+ --iux-text-dim: #C6BCB2;
107
+ --iux-text-faint: #8E8E92;
108
+
109
+ --iux-primary: #FFE9A8;
110
+ --iux-primary-light: #FFE9A8;
111
+ --iux-indigo: #FFE9A8;
112
+ --iux-accent-grad: #FFE9A8;
113
+ --iux-cyan: #7FE0C8;
114
+ --iux-lavender: #FFE9A8;
115
+ --iux-on-accent: #312F2E;
116
 
117
  --iux-success: #34D399;
118
+ --iux-warning: #F59E0B;
119
  --iux-danger: #FB7185;
120
  --iux-info: #38BDF8;
121
 
122
  --iux-radius-sm: 8px;
123
  --iux-radius: 14px;
124
  --iux-radius-lg: 20px;
125
+ --iux-shadow-sm: 0 2px 10px rgba(0,0,0,0.32);
126
+ --iux-shadow: 0 10px 34px rgba(10,8,4,0.5);
127
+ --iux-shadow-glow: 0 0 0 1px rgba(198,188,178,0.22), 0 8px 22px rgba(0,0,0,0.35);
128
  --iux-blur: blur(18px);
129
  --iux-ease: cubic-bezier(0.22, 1, 0.36, 1);
130
  --iux-font: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Manrope', Arial, sans-serif;
131
+
132
+ --iux-page-bg:
133
+ radial-gradient(circle at 18% 10%, color-mix(in srgb, var(--iux-primary-light) 16%, transparent), transparent 42%),
134
+ radial-gradient(circle at 82% 82%, color-mix(in srgb, var(--iux-indigo) 18%, transparent), transparent 45%),
135
+ var(--iux-bg);
136
  }
137
 
138
  :root[data-theme="light"] {
139
+ --iux-bg: #FBF7E7;
140
+ --iux-bg-alt: #F3EBC6;
141
+ --iux-surface: #F9F8F3;
142
+ --iux-surface-hi: #E6E7E4;
143
+ --iux-border: rgba(49, 47, 46, 0.10);
144
+ --iux-text: #312F2E;
145
+ --iux-text-dim: #747579;
146
+ --iux-text-faint: #8E8E92;
147
+
148
+ --iux-primary: #D9A400;
149
+ --iux-primary-light: #D9A400;
150
+ --iux-indigo: #D9A400;
151
+ --iux-accent-grad: #D9A400;
152
+ --iux-lavender: #B8860B;
153
+
154
+ --iux-shadow-sm: 0 2px 10px rgba(49,47,46,0.08);
155
+ --iux-shadow: 0 14px 34px rgba(49,47,46,0.12);
156
+ --iux-shadow-glow: 0 0 0 1px rgba(49,47,46,0.14), 0 8px 18px rgba(49,47,46,0.10);
157
+
158
+ --iux-page-bg:
159
+ linear-gradient(120deg, #E6E7E4 0%, #F9F8F3 45%, #FBF7E7 75%),
160
+ radial-gradient(circle at 88% 18%, color-mix(in srgb, #F3EBC6 90%, transparent), transparent 58%);
161
  }
162
 
163
  .iux-icon { display: inline-block; vertical-align: middle; flex-shrink: 0; }
 
258
  try { localStorage.setItem('__iux_prefs__', JSON.stringify(prefs)); } catch (e) {}
259
  }
260
  const prefs = readPrefs();
261
+ const theme = prefs.theme === 'dark' ? 'dark' : 'light';
262
  document.documentElement.setAttribute('data-theme', theme);
263
 
264
  window.insightuxGetPrefs = readPrefs;
 
267
  p[key] = value;
268
  writePrefs(p);
269
  };
270
+ window.insightuxGetTheme = function(){ return readPrefs().theme === 'dark' ? 'dark' : 'light'; };
271
  window.insightuxToggleTheme = function(){
272
  const next = window.insightuxGetTheme() === 'light' ? 'dark' : 'light';
273
  document.documentElement.setAttribute('data-theme', next);
validate.py CHANGED
@@ -43,6 +43,14 @@ else:
43
  RESOURCE_DIR = os.path.dirname(os.path.abspath(__file__))
44
  DATA_DIR = RESOURCE_DIR
45
 
 
 
 
 
 
 
 
 
46
  from preprocessing.preprocessing_pipeline import (
47
  create_face_mesh,
48
  estimate_camera_matrix,
@@ -64,6 +72,14 @@ from inference_pipeline import InsightUXPipeline, GazeAngleSmoother
64
  ONNX_PATH = os.path.join(RESOURCE_DIR, "models", "gaze_cnn_v4.onnx")
65
  CALIBRATION_PATH = os.path.join(DATA_DIR, "calibration.pkl")
66
 
 
 
 
 
 
 
 
 
67
  SCREEN_W, SCREEN_H = pyautogui.size()
68
  PATCH_SOURCE = "blended" # MUST match calibrate.py and main_webcam_pipeline.py
69
 
 
43
  RESOURCE_DIR = os.path.dirname(os.path.abspath(__file__))
44
  DATA_DIR = RESOURCE_DIR
45
 
46
+ # See calibrate.py's identical check — browser_session.py's run_validation()
47
+ # sets this to the active InsightUX profile's own folder so validation reads
48
+ # THAT profile's calibration.pkl, not the global/another profile's. Unset
49
+ # (standalone `python validate.py`) reproduces exactly today's behavior.
50
+ _USER_DATA_DIR = os.environ.get("INSIGHTUX_USER_DATA_DIR")
51
+ if _USER_DATA_DIR:
52
+ DATA_DIR = _USER_DATA_DIR
53
+
54
  from preprocessing.preprocessing_pipeline import (
55
  create_face_mesh,
56
  estimate_camera_matrix,
 
72
  ONNX_PATH = os.path.join(RESOURCE_DIR, "models", "gaze_cnn_v4.onnx")
73
  CALIBRATION_PATH = os.path.join(DATA_DIR, "calibration.pkl")
74
 
75
+ # Mirrors calibrate.py's _current_onnx_path()/browser_session.py's
76
+ # _user_onnx_path(): validate against the same model this profile's real
77
+ # tracking would actually use — its own fine-tuned model if it has one,
78
+ # the stock bundled model otherwise.
79
+ _USER_ONNX_OUT_PATH = os.environ.get("INSIGHTUX_USER_ONNX_OUT")
80
+ if _USER_ONNX_OUT_PATH and os.path.exists(_USER_ONNX_OUT_PATH):
81
+ ONNX_PATH = _USER_ONNX_OUT_PATH
82
+
83
  SCREEN_W, SCREEN_H = pyautogui.size()
84
  PATCH_SOURCE = "blended" # MUST match calibrate.py and main_webcam_pipeline.py
85