Aryaman25 Claude Sonnet 5 commited on
Commit
b85a07c
·
1 Parent(s): 1b83c9c

Redesign frontend UI with a shared purple/indigo theme system

Browse files

Adds theme.py as a single source of truth for CSS variables (dark/light),
an inline icon set, and theme-toggle persistence, then applies it across
every real UI surface: the landing page, injected toolbar/sidebar chrome,
gaze/mouse overlays, and the session report. Includes new status pills for
simultaneous eye/mouse tracking, a settings popover, heatmap opacity and
gaze-point controls, and a filterable ranked-attention table. Tracking,
calibration, and report-generation logic are unchanged — only presentation.

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

Files changed (4) hide show
  1. .gitignore +1 -0
  2. analysis.py +371 -120
  3. browser_session.py +443 -122
  4. theme.py +255 -0
.gitignore CHANGED
@@ -24,6 +24,7 @@
24
  !analysis.py
25
  !inference_pipeline.py
26
  !session_logger.py
 
27
 
28
  !packaging/InsightUX.spec
29
  !packaging/installer.iss
 
24
  !analysis.py
25
  !inference_pipeline.py
26
  !session_logger.py
27
+ !theme.py
28
 
29
  !packaging/InsightUX.spec
30
  !packaging/installer.iss
analysis.py CHANGED
@@ -19,6 +19,8 @@ import re
19
  import json
20
  import bisect
21
 
 
 
22
  PAD_PX = 90 # keep identical to TRACKING_JS's PAD_PX in browser_session.py
23
 
24
 
@@ -124,10 +126,10 @@ def friendly_label(raw):
124
  m = re.match(r"^(h[123]):\s*(.*)$", raw)
125
  if m:
126
  level = {"h1": "Main heading", "h2": "Heading", "h3": "Sub-heading"}[m.group(1)]
127
- return f"{level} — \u201c{m.group(2)}\u201d"
128
  m = re.match(r"^p \((.*)\)$", raw)
129
  if m:
130
- return f"Text — \u201c{m.group(1)}\u2026\u201d"
131
  if raw.startswith("#"):
132
  return "Section: " + raw[1:]
133
  if raw.startswith("."):
@@ -224,6 +226,23 @@ def session_summary(gaze, dom):
224
  return {"duration": round(duration, 1), "url": url, "samples": len(gaze)}
225
 
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  # =============================================================================
228
  # SCREENSHOT-BACKED HEATMAP SEGMENTS
229
  # Groups gaze points against whichever screenshot was on screen at the time,
@@ -276,74 +295,129 @@ _TEMPLATE = r"""<!DOCTYPE html>
276
  <meta name="insightux-report" content="true">
277
  <title>InsightUX Session Report</title>
278
  <style>
279
- :root {
280
- --purple: #7B2FBE; --purple-light: #9B59FF; --pink: #FF2DF0;
281
- --bg: #14121a; --panel: #1e1b26; --text: #f0ecf7; --dim: #9a92ad;
282
- }
283
  * { box-sizing: border-box; }
284
  body {
285
- margin: 0; padding: 32px; background: var(--bg); color: var(--text);
286
- font-family: -apple-system, 'Segoe UI', Arial, sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  }
288
- h1 { margin: 0 0 4px 0; font-size: 22px; }
289
- .sub { color: var(--dim); font-size: 13px; margin-bottom: 24px; }
290
- .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
291
- .panel {
292
- background: var(--panel); border: 1px solid #302a3d; border-radius: 12px;
293
- padding: 20px;
294
  }
295
- .panel h2 { margin: 0 0 14px 0; font-size: 14px; color: var(--purple-light);
296
- text-transform: uppercase; letter-spacing: 0.06em; }
297
- table { width: 100%; border-collapse: collapse; font-size: 13px; }
298
- th { text-align: left; color: var(--dim); font-weight: 500; padding: 6px 8px;
299
- border-bottom: 1px solid #302a3d; }
300
- td { padding: 7px 8px; border-bottom: 1px solid #241f2e; }
301
- .bar-bg { background: #2a2436; border-radius: 4px; height: 6px; margin-top: 4px; overflow: hidden; }
302
- .bar-fill { height: 100%; background: linear-gradient(90deg, var(--purple), var(--pink)); }
303
- canvas { display: block; }
304
  .full { grid-column: 1 / -1; }
305
- .stat-row { display: flex; gap: 24px; margin-bottom: 20px; }
306
- .stat { background: var(--panel); border: 1px solid #302a3d; border-radius: 12px;
307
- padding: 14px 20px; }
308
- .stat .v { font-size: 20px; font-weight: 600; color: var(--purple-light); }
309
- .stat .l { font-size: 11px; color: var(--dim); text-transform: uppercase; }
310
- .empty { color: var(--dim); font-size: 13px; padding: 20px 0; text-align: center; }
311
- .legend { font-size: 12px; color: var(--dim); line-height: 1.7; }
312
- .legend b { color: var(--text); }
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  .legend-scale { display: flex; align-items: center; gap: 8px; margin: 10px 0; }
314
- .legend-scale .bar { flex: 1; height: 10px; border-radius: 5px;
315
  background: linear-gradient(90deg, #0000ff, #00e5ff, #00ff5a, #e8ff00, #ff2d00); }
316
- .shot-wrap { position: relative; border-radius: 8px; overflow: hidden; background: #100e16; }
 
 
 
 
 
 
 
 
 
 
 
 
317
  .shot-wrap img, .shot-wrap canvas { display: block; width: 100%; height: auto; }
318
  .shot-wrap canvas { position: absolute; top: 0; left: 0; }
 
 
319
  .shot-nav { display: flex; align-items: center; justify-content: space-between;
320
- margin-top: 10px; font-size: 12px; color: var(--dim); }
321
- .shot-nav button {
322
- background: #2a2436; border: 1px solid #3a3348; color: var(--text);
323
- padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 12px;
324
- }
325
- .shot-nav button:hover { background: #352d44; }
326
- .shot-nav button:disabled { opacity: 0.35; cursor: default; }
327
  </style>
328
  </head>
329
  <body>
330
 
331
- <h1>InsightUX Session Report</h1>
332
- <div class="sub">__URL__ &nbsp;·&nbsp; generated from __SAMPLES__ gaze samples</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
333
 
334
- <div class="stat-row">
335
- <div class="stat"><div class="v">__DURATION__s</div><div class="l">Session Length</div></div>
336
- <div class="stat"><div class="v">__NUM_ELEMENTS__</div><div class="l">Elements Fixated</div></div>
337
- <div class="stat"><div class="v">__TOP_LABEL__</div><div class="l">Most Attended</div></div>
 
 
338
  </div>
339
 
340
  <div class="grid">
341
- <div class="panel">
342
- <h2>Ranked Attention</h2>
 
343
  <div id="rankingTable"></div>
344
  </div>
345
- <div class="panel">
346
- <h2>How to read this report</h2>
347
  <div class="legend">
348
  <p><b>Ranked Attention</b> — every part of the page you looked at for a
349
  meaningful stretch of time, ordered by how long you spent there.</p>
@@ -360,25 +434,46 @@ _TEMPLATE = r"""<!DOCTYPE html>
360
  so you can see the order things were looked at, not just the totals.</p>
361
  </div>
362
  </div>
363
- <div class="panel full">
364
- <h2>Heatmap by Scroll Position</h2>
 
 
 
 
 
 
 
 
 
 
365
  <div id="heatmapArea"></div>
366
  </div>
367
- <div class="panel full">
368
- <h2>Attention Timeline</h2>
369
  <canvas id="timeline" width="1240" height="320"></canvas>
370
  </div>
371
- <div class="panel">
372
- <h2>Mouse — Most Interacted Elements</h2>
373
  <div id="mouseInterests"></div>
374
  </div>
375
- <div class="panel">
376
- <h2>Mouse — Click Log</h2>
377
- <div id="mouseClicks" style="max-height:260px;overflow-y:auto;"></div>
 
378
  </div>
379
  </div>
380
 
381
  <script>
 
 
 
 
 
 
 
 
 
 
382
  const RANKING = __RANKING_JSON__;
383
  const TIMELINE = __TIMELINE_JSON__;
384
  const SEGMENTS = __SEGMENTS_JSON__;
@@ -386,15 +481,42 @@ const DURATION = __DURATION_JSON__;
386
  const MOUSE_INTERESTS = __MOUSE_INTERESTS_JSON__;
387
  const MOUSE_CLICKS = __MOUSE_CLICKS_JSON__;
388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  // ---------- Mouse interests / clicks (from the in-page Mouse Tracker) ----------
390
  (function(){
391
  const el = document.getElementById('mouseInterests');
392
  if (!MOUSE_INTERESTS.length) {
393
- el.innerHTML = '<div class="empty">No mouse dwell data recorded for this session.</div>';
394
  } else {
395
  let html = '<table><tr><th>#</th><th>Element</th><th>Time</th></tr>';
396
  MOUSE_INTERESTS.forEach((r, i) => {
397
- html += `<tr><td>${i+1}</td><td>${r.element}</td><td>${r.seconds}s</td></tr>`;
398
  });
399
  html += '</table>';
400
  el.innerHTML = html;
@@ -402,37 +524,69 @@ const MOUSE_CLICKS = __MOUSE_CLICKS_JSON__;
402
 
403
  const clickEl = document.getElementById('mouseClicks');
404
  if (!MOUSE_CLICKS.length) {
405
- clickEl.innerHTML = '<div class="empty">No clicks recorded for this session.</div>';
406
  } else {
407
  let html = '<ul style="padding-left:0;margin:0;list-style:none;">';
408
  MOUSE_CLICKS.slice().reverse().forEach(c => {
409
- html += `<li style="margin-bottom:8px;border-bottom:1px solid #302a3d;padding-bottom:6px;">
410
- <span style="color:var(--dim);font-size:11px;">${c.timestamp || ''}</span><br>
411
  <b>${c.element || ''}</b><br>
412
- <span style="color:var(--text);font-size:12px;">"${(c.text || '').replace(/</g,'&lt;')}"</span>
413
  </li>`;
414
  });
415
  html += '</ul>';
416
  clickEl.innerHTML = html;
417
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  })();
419
 
420
- // ---------- Ranked table ----------
421
  (function(){
422
  const el = document.getElementById('rankingTable');
423
- if (!RANKING.length) { el.innerHTML = '<div class="empty">No elements were fixated long enough to register.</div>'; return; }
424
- let html = '<table><tr><th>#</th><th>Element</th><th>Dwell</th><th>Share</th><th>Hits</th></tr>';
425
- RANKING.slice(0, 15).forEach((r, i) => {
426
- html += `<tr>
427
- <td>${i+1}</td>
428
- <td>${r.label}<div class="bar-bg"><div class="bar-fill" style="width:${r.pct}%"></div></div></td>
429
- <td>${r.seconds}s</td>
430
- <td>${r.pct}%</td>
431
- <td>${r.hits}</td>
432
- </tr>`;
433
- });
434
- html += '</table>';
435
- el.innerHTML = html;
 
 
 
 
 
 
 
 
 
 
 
 
436
  })();
437
 
438
  // ---------- Classic red/green/blue heat colormap ----------
@@ -458,25 +612,50 @@ function jetColor(t){
458
  // ---------- Screenshot-backed heatmaps, paginated ----------
459
  (function(){
460
  const area = document.getElementById('heatmapArea');
 
461
  if (!SEGMENTS.length){
462
- area.innerHTML = '<div class="empty">No page screenshots were captured for this session ' +
463
  '(older session, or screen-capture failed) — nothing to overlay a heatmap on.</div>';
464
  return;
465
  }
 
466
 
467
  let cur = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
 
469
  function renderSegment(i){
470
  const seg = SEGMENTS[i];
471
  area.innerHTML = `
472
- <div class="shot-wrap" id="shotWrap">
473
  <img id="shotImg" src="${seg.screenshot}">
474
  <canvas id="shotCanvas"></canvas>
475
  </div>
476
  <div class="shot-nav">
477
- <button id="prevBtn" ${i===0?'disabled':''}>&larr; Earlier</button>
478
  <span>Scroll segment ${i+1} of ${SEGMENTS.length} &middot; ~${seg.duration}s of attention here</span>
479
- <button id="nextBtn" ${i===SEGMENTS.length-1?'disabled':''}>Later &rarr;</button>
480
  </div>
481
  `;
482
 
@@ -484,7 +663,7 @@ function jetColor(t){
484
  const cv = document.getElementById('shotCanvas');
485
  const ctx = cv.getContext('2d');
486
 
487
- function paint(){
488
  cv.width = img.naturalWidth;
489
  cv.height = img.naturalHeight;
490
 
@@ -494,7 +673,7 @@ function jetColor(t){
494
 
495
  seg.points.forEach(p => {
496
  const grad = octx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, 55);
497
- grad.addColorStop(0, 'rgba(255,255,255,0.09)');
498
  grad.addColorStop(1, 'rgba(255,255,255,0)');
499
  octx.fillStyle = grad;
500
  octx.beginPath();
@@ -509,13 +688,33 @@ function jetColor(t){
509
  if (a <= 0) continue;
510
  const t = Math.min(1, a * 3.0);
511
  const [r,g,b] = jetColor(t);
512
- d[k] = r; d[k+1] = g; d[k+2] = b; d[k+3] = Math.min(235, a*520);
513
  }
514
  octx.putImageData(idata, 0, 0);
515
  ctx.clearRect(0,0,cv.width,cv.height);
516
  ctx.drawImage(off, 0, 0);
517
  }
518
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  if (img.complete) paint(); else img.onload = paint;
520
 
521
  document.getElementById('prevBtn').onclick = () => { if (cur>0){ cur--; renderSegment(cur); } };
@@ -529,41 +728,72 @@ function jetColor(t){
529
  (function(){
530
  const cv = document.getElementById('timeline');
531
  const ctx = cv.getContext('2d');
532
- ctx.fillStyle = '#100e16';
533
- ctx.fillRect(0, 0, cv.width, cv.height);
534
- if (!TIMELINE.length || DURATION <= 0) { return; }
535
-
536
- const labels = [...new Set(TIMELINE.filter(s => s.label).map(s => s.label))];
537
- const rowH = Math.min(28, (cv.height - 40) / Math.max(labels.length, 1));
538
- const leftPad = 220, topPad = 10, plotW = cv.width - leftPad - 20;
539
-
540
- ctx.font = '11px -apple-system, Arial';
541
- ctx.fillStyle = '#9a92ad';
542
- labels.forEach((lab, i) => {
543
- const y = topPad + i * rowH;
544
- ctx.fillText(lab.length > 28 ? lab.slice(0,28)+'…' : lab, 4, y + rowH*0.65);
545
- });
546
 
547
- const colors = ['#7B2FBE', '#9B59FF', '#FF2DF0', '#5EC8D8', '#F0A868', '#7BE0A0'];
548
- TIMELINE.forEach(seg => {
549
- if (!seg.label) return;
550
- const rowIdx = labels.indexOf(seg.label);
551
- const x = leftPad + (seg.start / DURATION) * plotW;
552
- const w = Math.max(2, ((seg.end - seg.start) / DURATION) * plotW);
553
- const y = topPad + rowIdx * rowH + 3;
554
- ctx.fillStyle = colors[rowIdx % colors.length];
555
- ctx.fillRect(x, y, w, rowH - 6);
556
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
 
558
- ctx.strokeStyle = '#302a3d';
559
- ctx.beginPath();
560
- ctx.moveTo(leftPad, cv.height - 18);
561
- ctx.lineTo(cv.width - 10, cv.height - 18);
562
- ctx.stroke();
563
- ctx.fillStyle = '#9a92ad';
564
- [0, 0.25, 0.5, 0.75, 1].forEach(f => {
565
- const x = leftPad + f * plotW;
566
- ctx.fillText((f * DURATION).toFixed(1) + 's', x, cv.height - 4);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
567
  });
568
  })();
569
  </script>
@@ -581,6 +811,7 @@ def generate_report(session_dir):
581
  segments = build_screenshot_segments(gaze, dom)
582
  summary = session_summary(gaze, dom)
583
  mouse = summarize_mouse(session_dir)
 
584
 
585
  top_label = ranking[0]["label"] if ranking else "—"
586
 
@@ -590,6 +821,8 @@ def generate_report(session_dir):
590
  html = html.replace("__DURATION__", str(summary["duration"]))
591
  html = html.replace("__NUM_ELEMENTS__", str(len(ranking)))
592
  html = html.replace("__TOP_LABEL__", top_label)
 
 
593
  html = html.replace("__RANKING_JSON__", json.dumps(ranking))
594
  html = html.replace("__TIMELINE_JSON__", json.dumps(timeline))
595
  html = html.replace("__SEGMENTS_JSON__", json.dumps(segments))
@@ -597,6 +830,24 @@ def generate_report(session_dir):
597
  html = html.replace("__MOUSE_INTERESTS_JSON__", json.dumps(mouse["interests"]))
598
  html = html.replace("__MOUSE_CLICKS_JSON__", json.dumps(mouse["clicks"]))
599
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
600
  out_path = os.path.join(session_dir, "analysis_report.html")
601
  with open(out_path, "w", encoding="utf-8") as f:
602
  f.write(html)
@@ -608,4 +859,4 @@ if __name__ == "__main__":
608
  import sys
609
  session_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.join("sessions", "live")
610
  path = generate_report(session_dir)
611
- print(f"Report written to: {path}")
 
19
  import json
20
  import bisect
21
 
22
+ import theme
23
+
24
  PAD_PX = 90 # keep identical to TRACKING_JS's PAD_PX in browser_session.py
25
 
26
 
 
126
  m = re.match(r"^(h[123]):\s*(.*)$", raw)
127
  if m:
128
  level = {"h1": "Main heading", "h2": "Heading", "h3": "Sub-heading"}[m.group(1)]
129
+ return f"{level} — {m.group(2)}"
130
  m = re.match(r"^p \((.*)\)$", raw)
131
  if m:
132
+ return f"Text — {m.group(1)}…”"
133
  if raw.startswith("#"):
134
  return "Section: " + raw[1:]
135
  if raw.startswith("."):
 
226
  return {"duration": round(duration, 1), "url": url, "samples": len(gaze)}
227
 
228
 
229
+ def compute_scroll_depth(dom):
230
+ """Max % of page height reached, derived from the scrollY/page/viewport
231
+ fields already present in every dom snapshot (insightuxAOIs() in
232
+ TRACKING_JS) — no new data collection, just a derived metric."""
233
+ best = 0.0
234
+ for d in dom:
235
+ page_h = (d.get("page") or {}).get("h") or 0
236
+ viewport_h = (d.get("viewport") or {}).get("h") or 0
237
+ scroll_y = d.get("scrollY", 0)
238
+ if page_h <= 0:
239
+ continue
240
+ pct = min(100.0, 100.0 * (scroll_y + viewport_h) / page_h)
241
+ if pct > best:
242
+ best = pct
243
+ return round(best)
244
+
245
+
246
  # =============================================================================
247
  # SCREENSHOT-BACKED HEATMAP SEGMENTS
248
  # Groups gaze points against whichever screenshot was on screen at the time,
 
295
  <meta name="insightux-report" content="true">
296
  <title>InsightUX Session Report</title>
297
  <style>
298
+ __THEME_CSS__
 
 
 
299
  * { box-sizing: border-box; }
300
  body {
301
+ margin: 0; padding: 28px 32px 48px; background: var(--iux-bg); color: var(--iux-text);
302
+ font-family: var(--iux-font); transition: background .3s var(--iux-ease), color .3s var(--iux-ease);
303
+ }
304
+ .topbar { display: flex; align-items: center; gap: 14px; margin-bottom: 22px; }
305
+ .topbar .brand { display: flex; align-items: center; gap: 10px; }
306
+ .topbar .mark {
307
+ width: 34px; height: 34px; border-radius: 10px; background: var(--iux-accent-grad);
308
+ display: flex; align-items: center; justify-content: center; color: #fff; flex-shrink: 0;
309
+ }
310
+ h1 { margin: 0; font-size: 19px; font-weight: 700; }
311
+ .sub { color: var(--iux-text-dim); font-size: 12.5px; margin-top: 2px; }
312
+ .topbar .actions { margin-left: auto; display: flex; gap: 8px; }
313
+ .topbar .actions .iux-btn { padding: 8px 13px; font-size: 12px; }
314
+
315
+ .stat-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 22px; }
316
+ .stat {
317
+ padding: 16px 18px; position: relative; overflow: hidden;
318
+ }
319
+ .stat .icon-badge {
320
+ width: 30px; height: 30px; border-radius: 9px; display: flex; align-items: center; justify-content: center;
321
+ background: var(--iux-accent-grad); color: #fff; margin-bottom: 10px;
322
  }
323
+ .stat .v { font-size: 21px; font-weight: 700; color: var(--iux-text); }
324
+ .stat .l { font-size: 10.5px; color: var(--iux-text-faint); text-transform: uppercase; letter-spacing: .05em; margin-top: 2px; }
325
+ .stat::after {
326
+ content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 3px;
327
+ background: var(--iux-accent-grad); opacity: .7;
 
328
  }
329
+
330
+ .grid { display: grid; grid-template-columns: 1.3fr 1fr; gap: 18px; margin-bottom: 18px; }
331
+ .panel { padding: 20px; }
332
+ .panel h2 {
333
+ margin: 0 0 14px 0; font-size: 12.5px; color: var(--iux-text-dim); font-weight: 700;
334
+ text-transform: uppercase; letter-spacing: 0.07em; display: flex; align-items: center; gap: 7px;
335
+ }
336
+ .panel h2 .n { margin-left: auto; color: var(--iux-text-faint); font-weight: 500; text-transform: none; letter-spacing: 0; }
 
337
  .full { grid-column: 1 / -1; }
338
+
339
+ table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
340
+ th { text-align: left; color: var(--iux-text-faint); font-weight: 600; padding: 7px 8px;
341
+ border-bottom: 1px solid var(--iux-border); font-size: 11px; text-transform: uppercase; letter-spacing: .03em; }
342
+ td { padding: 9px 8px; border-bottom: 1px solid var(--iux-border); vertical-align: middle; }
343
+ tr.data-row { transition: background .12s ease; cursor: default; }
344
+ tr.data-row:hover { background: var(--iux-surface-hi); }
345
+ .row-el { display: flex; align-items: center; gap: 8px; }
346
+ .row-el .ri { color: var(--iux-primary-light); flex-shrink: 0; }
347
+ .bar-bg { background: var(--iux-surface-hi); border-radius: 4px; height: 5px; margin-top: 5px; overflow: hidden; }
348
+ .bar-fill { height: 100%; background: var(--iux-accent-grad); border-radius: 4px; transition: width .4s var(--iux-ease); }
349
+
350
+ #rankingFilter {
351
+ width: 100%; padding: 7px 12px; border-radius: 8px; border: 1px solid var(--iux-border);
352
+ background: var(--iux-surface-hi); color: var(--iux-text); font-size: 12px; outline: none; margin-bottom: 10px;
353
+ }
354
+
355
+ .empty { color: var(--iux-text-faint); font-size: 12.5px; padding: 30px 10px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; }
356
+ .empty .ei { opacity: .5; }
357
+ .legend { font-size: 12px; color: var(--iux-text-dim); line-height: 1.7; }
358
+ .legend b { color: var(--iux-text); }
359
  .legend-scale { display: flex; align-items: center; gap: 8px; margin: 10px 0; }
360
+ .legend-scale .bar { flex: 1; height: 9px; border-radius: 5px;
361
  background: linear-gradient(90deg, #0000ff, #00e5ff, #00ff5a, #e8ff00, #ff2d00); }
362
+
363
+ .hm-toolbar { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; flex-wrap: wrap; }
364
+ .hm-toolbar .seg { display: flex; gap: 4px; background: var(--iux-surface-hi); padding: 3px; border-radius: 999px; }
365
+ .hm-toolbar .seg button {
366
+ border: none; background: transparent; color: var(--iux-text-dim); padding: 6px 12px; border-radius: 999px;
367
+ font-size: 11.5px; cursor: pointer; font-family: var(--iux-font);
368
+ }
369
+ .hm-toolbar .seg button.on { background: var(--iux-accent-grad); color: #fff; }
370
+ .hm-toolbar .slider-mini { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--iux-text-faint); }
371
+ .hm-toolbar .slider-mini input { accent-color: var(--iux-primary-light); }
372
+ .hm-toolbar .iux-btn { padding: 7px 10px; font-size: 11.5px; margin-left: auto; }
373
+
374
+ .shot-wrap { position: relative; border-radius: var(--iux-radius); overflow: hidden; background: var(--iux-bg-alt); border: 1px solid var(--iux-border); }
375
  .shot-wrap img, .shot-wrap canvas { display: block; width: 100%; height: auto; }
376
  .shot-wrap canvas { position: absolute; top: 0; left: 0; }
377
+ .shot-wrap.fullscreen { position: fixed; inset: 16px; z-index: 999999; border-radius: var(--iux-radius-lg); }
378
+ .shot-wrap.fullscreen img, .shot-wrap.fullscreen canvas { width: 100%; height: 100%; object-fit: contain; }
379
  .shot-nav { display: flex; align-items: center; justify-content: space-between;
380
+ margin-top: 10px; font-size: 12px; color: var(--iux-text-dim); }
381
+ .shot-nav .iux-btn { padding: 7px 14px; font-size: 12px; }
382
+ .shot-nav .iux-btn:disabled { opacity: 0.35; cursor: default; transform:none; }
383
+
384
+ .click-timeline { width: 100%; display: block; margin-top: 8px; }
 
 
385
  </style>
386
  </head>
387
  <body>
388
 
389
+ <div class="topbar iux-fade-in">
390
+ <div class="brand">
391
+ <div class="mark">__EYE_ICON__</div>
392
+ <div>
393
+ <h1>InsightUX Session Report</h1>
394
+ <div class="sub">__URL__ &nbsp;&middot;&nbsp; __SAMPLES__ gaze samples</div>
395
+ </div>
396
+ </div>
397
+ <div class="actions">
398
+ <button class="iux-btn" id="btnFilter" title="Filter ranked attention">__FILTER_ICON__ Filter</button>
399
+ <button class="iux-btn" id="btnCopy" title="Copy summary to clipboard">__COPY_ICON__ Copy</button>
400
+ <button class="iux-btn primary" id="btnExport" title="Export / print">__EXPORT_ICON__ Export</button>
401
+ <button class="iux-btn" id="themeToggle" title="Toggle theme" style="width:36px;"></button>
402
+ </div>
403
+ </div>
404
 
405
+ <div class="stat-row iux-fade-in">
406
+ <div class="stat iux-card hoverable"><div class="icon-badge">__CLOCK_ICON__</div><div class="v">__DURATION__s</div><div class="l">Session Length</div></div>
407
+ <div class="stat iux-card hoverable"><div class="icon-badge">__LAYERS_ICON__</div><div class="v">__NUM_ELEMENTS__</div><div class="l">Elements Fixated</div></div>
408
+ <div class="stat iux-card hoverable"><div class="icon-badge">__TARGET_ICON__</div><div class="v" style="font-size:14px;">__TOP_LABEL__</div><div class="l">Most Attended</div></div>
409
+ <div class="stat iux-card hoverable"><div class="icon-badge">__CURSOR_ICON__</div><div class="v">__CLICK_COUNT__</div><div class="l">Mouse Events</div></div>
410
+ <div class="stat iux-card hoverable"><div class="icon-badge">__CHEVRON_ICON__</div><div class="v">__SCROLL_DEPTH__%</div><div class="l">Scroll Depth</div></div>
411
  </div>
412
 
413
  <div class="grid">
414
+ <div class="panel iux-card iux-fade-in">
415
+ <h2>__TYPE_ICON__ Ranked Attention <span class="n" id="rankCount"></span></h2>
416
+ <input type="text" id="rankingFilter" placeholder="Filter by element name...">
417
  <div id="rankingTable"></div>
418
  </div>
419
+ <div class="panel iux-card iux-fade-in">
420
+ <h2>__INFO_ICON__ How to read this report</h2>
421
  <div class="legend">
422
  <p><b>Ranked Attention</b> — every part of the page you looked at for a
423
  meaningful stretch of time, ordered by how long you spent there.</p>
 
434
  so you can see the order things were looked at, not just the totals.</p>
435
  </div>
436
  </div>
437
+ <div class="panel iux-card full iux-fade-in">
438
+ <h2>__LAYERS_ICON__ Heatmap by Scroll Position</h2>
439
+ <div class="hm-toolbar" id="hmToolbar" style="display:none;">
440
+ <div class="seg">
441
+ <button id="modeHeat" class="on">Heatmap</button>
442
+ <button id="modeGaze">Gaze Points</button>
443
+ </div>
444
+ <div class="slider-mini">Opacity <input type="range" id="hmOpacity" min="30" max="100" value="90"></div>
445
+ <div class="slider-mini">Intensity <input type="range" id="hmIntensity" min="1" max="10" value="3"></div>
446
+ <button class="iux-btn" id="hmFullscreen">__MAXIMIZE_ICON__</button>
447
+ <button class="iux-btn" id="hmDownload">__DOWNLOAD_ICON__</button>
448
+ </div>
449
  <div id="heatmapArea"></div>
450
  </div>
451
+ <div class="panel iux-card full iux-fade-in">
452
+ <h2>__CLOCK_ICON__ Attention Timeline</h2>
453
  <canvas id="timeline" width="1240" height="320"></canvas>
454
  </div>
455
+ <div class="panel iux-card iux-fade-in">
456
+ <h2>__CURSOR_ICON__ Mouse — Most Interacted Elements</h2>
457
  <div id="mouseInterests"></div>
458
  </div>
459
+ <div class="panel iux-card iux-fade-in">
460
+ <h2>__CLICK_ICON__ Mouse — Click Timeline &amp; Log</h2>
461
+ <canvas class="click-timeline" id="clickTimeline" width="560" height="60"></canvas>
462
+ <div id="mouseClicks" style="max-height:220px;overflow-y:auto;margin-top:10px;"></div>
463
  </div>
464
  </div>
465
 
466
  <script>
467
+ __ICONS_JS__
468
+ __THEME_JS__
469
+ document.getElementById('themeToggle').innerHTML = iuxIcon(window.insightuxGetTheme() === 'light' ? 'moon' : 'sun', 16);
470
+ window.insightuxRepaintCanvases = [];
471
+ document.getElementById('themeToggle').addEventListener('click', function(){
472
+ const next = window.insightuxToggleTheme();
473
+ this.innerHTML = iuxIcon(next === 'light' ? 'moon' : 'sun', 16);
474
+ window.insightuxRepaintCanvases.forEach(function(fn){ try { fn(); } catch(e){} });
475
+ });
476
+
477
  const RANKING = __RANKING_JSON__;
478
  const TIMELINE = __TIMELINE_JSON__;
479
  const SEGMENTS = __SEGMENTS_JSON__;
 
481
  const MOUSE_INTERESTS = __MOUSE_INTERESTS_JSON__;
482
  const MOUSE_CLICKS = __MOUSE_CLICKS_JSON__;
483
 
484
+ document.getElementById('btnExport').addEventListener('click', function(){ window.print(); });
485
+ document.getElementById('btnCopy').addEventListener('click', function(){
486
+ const summary = 'InsightUX Session Report\n' +
487
+ 'Duration: __DURATION__s | Elements fixated: __NUM_ELEMENTS__ | Most attended: __TOP_LABEL__\n' +
488
+ 'Top attention:\n' + RANKING.slice(0,5).map(r => '- ' + r.label + ' (' + r.seconds + 's, ' + r.pct + '%)').join('\n');
489
+ if (navigator.clipboard) navigator.clipboard.writeText(summary).catch(function(){});
490
+ const btn = document.getElementById('btnCopy');
491
+ const original = btn.innerHTML;
492
+ btn.innerHTML = iuxIcon('check', 14) + ' Copied';
493
+ setTimeout(function(){ btn.innerHTML = original; }, 1400);
494
+ });
495
+ document.getElementById('btnFilter').addEventListener('click', function(){
496
+ const input = document.getElementById('rankingFilter');
497
+ input.focus();
498
+ });
499
+
500
+ // ---------- icon inference from friendly_label() output ----------
501
+ function iconForLabel(label){
502
+ if (!label) return 'click';
503
+ if (label.indexOf('Image') === 0) return 'image';
504
+ if (label.indexOf('Video') === 0 || label.indexOf('Embedded') === 0) return 'video';
505
+ if (label.indexOf('heading') !== -1 || label.indexOf('Heading') === 0 || label.indexOf('Text') === 0) return 'type';
506
+ if (label.indexOf('Navigation') === 0) return 'menu';
507
+ if (label.indexOf('Page header') === 0 || label.indexOf('Page footer') === 0) return 'layers';
508
+ return 'click';
509
+ }
510
+
511
  // ---------- Mouse interests / clicks (from the in-page Mouse Tracker) ----------
512
  (function(){
513
  const el = document.getElementById('mouseInterests');
514
  if (!MOUSE_INTERESTS.length) {
515
+ el.innerHTML = '<div class="empty"><span class="ei">' + iuxIcon('cursor', 26) + '</span>No mouse dwell data recorded for this session.</div>';
516
  } else {
517
  let html = '<table><tr><th>#</th><th>Element</th><th>Time</th></tr>';
518
  MOUSE_INTERESTS.forEach((r, i) => {
519
+ html += `<tr class="data-row"><td>${i+1}</td><td>${r.element}</td><td>${r.seconds}s</td></tr>`;
520
  });
521
  html += '</table>';
522
  el.innerHTML = html;
 
524
 
525
  const clickEl = document.getElementById('mouseClicks');
526
  if (!MOUSE_CLICKS.length) {
527
+ clickEl.innerHTML = '<div class="empty"><span class="ei">' + iuxIcon('click', 26) + '</span>No clicks recorded for this session.</div>';
528
  } else {
529
  let html = '<ul style="padding-left:0;margin:0;list-style:none;">';
530
  MOUSE_CLICKS.slice().reverse().forEach(c => {
531
+ html += `<li style="margin-bottom:8px;border-bottom:1px solid var(--iux-border);padding-bottom:8px;">
532
+ <span style="color:var(--iux-text-faint);font-size:11px;">${c.timestamp || ''}</span><br>
533
  <b>${c.element || ''}</b><br>
534
+ <span style="color:var(--iux-text-dim);font-size:12px;">"${(c.text || '').replace(/</g,'&lt;')}"</span>
535
  </li>`;
536
  });
537
  html += '</ul>';
538
  clickEl.innerHTML = html;
539
  }
540
+
541
+ // ---- click timeline mini-chart (derived from the same click timestamps) ----
542
+ const cv = document.getElementById('clickTimeline');
543
+ const ctx = cv.getContext('2d');
544
+ function resizeClickCanvas(){ cv.width = cv.clientWidth || 560; cv.height = 60; }
545
+ resizeClickCanvas();
546
+ ctx.clearRect(0, 0, cv.width, cv.height);
547
+ if (MOUSE_CLICKS.length > 1) {
548
+ 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); });
549
+ const tmin = Math.min(...times), tmax = Math.max(...times) || tmin + 1;
550
+ ctx.strokeStyle = 'rgba(139,92,246,0.25)';
551
+ ctx.beginPath(); ctx.moveTo(0, cv.height - 10); ctx.lineTo(cv.width, cv.height - 10); ctx.stroke();
552
+ times.forEach(function(t){
553
+ const x = ((t - tmin) / (tmax - tmin || 1)) * (cv.width - 12) + 6;
554
+ const grad = ctx.createLinearGradient(x, 6, x, cv.height - 10);
555
+ grad.addColorStop(0, '#C084FC'); grad.addColorStop(1, '#6366F1');
556
+ ctx.fillStyle = grad;
557
+ ctx.beginPath(); ctx.arc(x, cv.height - 10, 4, 0, 2*Math.PI); ctx.fill();
558
+ });
559
+ }
560
  })();
561
 
562
+ // ---------- Ranked table (with live filter) ----------
563
  (function(){
564
  const el = document.getElementById('rankingTable');
565
+ const filterInput = document.getElementById('rankingFilter');
566
+ document.getElementById('rankCount').textContent = RANKING.length ? RANKING.length + ' items' : '';
567
+ if (!RANKING.length) {
568
+ filterInput.style.display = 'none';
569
+ el.innerHTML = '<div class="empty"><span class="ei">' + iuxIcon('target', 26) + '</span>No elements were fixated long enough to register.</div>';
570
+ return;
571
+ }
572
+ function render(filterText){
573
+ const rows = RANKING.filter(r => !filterText || r.label.toLowerCase().indexOf(filterText.toLowerCase()) !== -1).slice(0, 25);
574
+ if (!rows.length) { el.innerHTML = '<div class="empty">No elements match "' + filterText + '".</div>'; return; }
575
+ let html = '<table><tr><th>#</th><th>Element</th><th>Dwell</th><th>Share</th><th>Hits</th></tr>';
576
+ rows.forEach((r, i) => {
577
+ html += `<tr class="data-row">
578
+ <td>${i+1}</td>
579
+ <td><div class="row-el"><span class="ri">${iuxIcon(iconForLabel(r.label), 15)}</span><span>${r.label}<div class="bar-bg"><div class="bar-fill" style="width:${r.pct}%"></div></div></span></div></td>
580
+ <td>${r.seconds}s</td>
581
+ <td>${r.pct}%</td>
582
+ <td>${r.hits}</td>
583
+ </tr>`;
584
+ });
585
+ html += '</table>';
586
+ el.innerHTML = html;
587
+ }
588
+ render('');
589
+ filterInput.addEventListener('input', function(e){ render(e.target.value); });
590
  })();
591
 
592
  // ---------- Classic red/green/blue heat colormap ----------
 
612
  // ---------- Screenshot-backed heatmaps, paginated ----------
613
  (function(){
614
  const area = document.getElementById('heatmapArea');
615
+ const toolbar = document.getElementById('hmToolbar');
616
  if (!SEGMENTS.length){
617
+ area.innerHTML = '<div class="empty"><span class="ei">' + iuxIcon('layers', 26) + '</span>No page screenshots were captured for this session ' +
618
  '(older session, or screen-capture failed) — nothing to overlay a heatmap on.</div>';
619
  return;
620
  }
621
+ toolbar.style.display = 'flex';
622
 
623
  let cur = 0;
624
+ let mode = 'heat'; // 'heat' | 'gaze'
625
+ let opacity = 0.9, intensity = 0.09;
626
+ let fullscreen = false;
627
+
628
+ const modeHeatBtn = document.getElementById('modeHeat');
629
+ const modeGazeBtn = document.getElementById('modeGaze');
630
+ modeHeatBtn.addEventListener('click', function(){ mode = 'heat'; modeHeatBtn.classList.add('on'); modeGazeBtn.classList.remove('on'); renderSegment(cur); });
631
+ modeGazeBtn.addEventListener('click', function(){ mode = 'gaze'; modeGazeBtn.classList.add('on'); modeHeatBtn.classList.remove('on'); renderSegment(cur); });
632
+ document.getElementById('hmOpacity').addEventListener('input', function(e){ opacity = e.target.value/100; renderSegment(cur); });
633
+ document.getElementById('hmIntensity').addEventListener('input', function(e){ intensity = e.target.value/33; renderSegment(cur); });
634
+ document.getElementById('hmFullscreen').addEventListener('click', function(){
635
+ fullscreen = !fullscreen;
636
+ const wrap = document.getElementById('shotWrap');
637
+ if (wrap) wrap.classList.toggle('fullscreen', fullscreen);
638
+ });
639
+ document.getElementById('hmDownload').addEventListener('click', function(){
640
+ const cv = document.getElementById('shotCanvas');
641
+ if (!cv) return;
642
+ const a = document.createElement('a');
643
+ a.href = cv.toDataURL('image/png');
644
+ a.download = 'insightux-heatmap-segment-' + (cur+1) + '.png';
645
+ a.click();
646
+ });
647
 
648
  function renderSegment(i){
649
  const seg = SEGMENTS[i];
650
  area.innerHTML = `
651
+ <div class="shot-wrap${fullscreen ? ' fullscreen' : ''}" id="shotWrap">
652
  <img id="shotImg" src="${seg.screenshot}">
653
  <canvas id="shotCanvas"></canvas>
654
  </div>
655
  <div class="shot-nav">
656
+ <button class="iux-btn" id="prevBtn" ${i===0?'disabled':''}>${iuxIcon('chevron-left',13)} Earlier</button>
657
  <span>Scroll segment ${i+1} of ${SEGMENTS.length} &middot; ~${seg.duration}s of attention here</span>
658
+ <button class="iux-btn" id="nextBtn" ${i===SEGMENTS.length-1?'disabled':''}>Later ${iuxIcon('chevron-right',13)}</button>
659
  </div>
660
  `;
661
 
 
663
  const cv = document.getElementById('shotCanvas');
664
  const ctx = cv.getContext('2d');
665
 
666
+ function paintHeat(){
667
  cv.width = img.naturalWidth;
668
  cv.height = img.naturalHeight;
669
 
 
673
 
674
  seg.points.forEach(p => {
675
  const grad = octx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, 55);
676
+ grad.addColorStop(0, 'rgba(255,255,255,' + intensity + ')');
677
  grad.addColorStop(1, 'rgba(255,255,255,0)');
678
  octx.fillStyle = grad;
679
  octx.beginPath();
 
688
  if (a <= 0) continue;
689
  const t = Math.min(1, a * 3.0);
690
  const [r,g,b] = jetColor(t);
691
+ d[k] = r; d[k+1] = g; d[k+2] = b; d[k+3] = Math.min(235, a*520) * opacity;
692
  }
693
  octx.putImageData(idata, 0, 0);
694
  ctx.clearRect(0,0,cv.width,cv.height);
695
  ctx.drawImage(off, 0, 0);
696
  }
697
 
698
+ function paintGaze(){
699
+ cv.width = img.naturalWidth;
700
+ cv.height = img.naturalHeight;
701
+ ctx.clearRect(0,0,cv.width,cv.height);
702
+ ctx.save();
703
+ ctx.globalAlpha = opacity;
704
+ seg.points.forEach(p => {
705
+ ctx.beginPath();
706
+ ctx.arc(p.sx, p.sy, 5, 0, 2*Math.PI);
707
+ ctx.fillStyle = '#C084FC';
708
+ ctx.fill();
709
+ ctx.lineWidth = 1.2;
710
+ ctx.strokeStyle = 'rgba(18,19,26,0.6)';
711
+ ctx.stroke();
712
+ });
713
+ ctx.restore();
714
+ }
715
+
716
+ function paint(){ mode === 'heat' ? paintHeat() : paintGaze(); }
717
+
718
  if (img.complete) paint(); else img.onload = paint;
719
 
720
  document.getElementById('prevBtn').onclick = () => { if (cur>0){ cur--; renderSegment(cur); } };
 
728
  (function(){
729
  const cv = document.getElementById('timeline');
730
  const ctx = cv.getContext('2d');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
731
 
732
+ function paintTimeline(){
733
+ const bg = getComputedStyle(document.documentElement).getPropertyValue('--iux-bg-alt').trim() || '#171923';
734
+ const dim = getComputedStyle(document.documentElement).getPropertyValue('--iux-text-dim').trim() || '#9a95b3';
735
+ ctx.clearRect(0, 0, cv.width, cv.height);
736
+ ctx.fillStyle = bg;
737
+ ctx.fillRect(0, 0, cv.width, cv.height);
738
+ if (!TIMELINE.length || DURATION <= 0) { return; }
739
+
740
+ const labels = [...new Set(TIMELINE.filter(s => s.label).map(s => s.label))];
741
+ const rowH = Math.min(28, (cv.height - 40) / Math.max(labels.length, 1));
742
+ const leftPad = 220, topPad = 10, plotW = cv.width - leftPad - 20;
743
+
744
+ ctx.font = '11px -apple-system, Arial';
745
+ ctx.fillStyle = dim;
746
+ labels.forEach((lab, i) => {
747
+ const y = topPad + i * rowH;
748
+ ctx.fillText(lab.length > 28 ? lab.slice(0,28)+'…' : lab, 4, y + rowH*0.65);
749
+ });
750
+
751
+ const colors = ['#6366F1', '#8B5CF6', '#C084FC', '#67E8F9', '#FBBF24', '#34D399'];
752
+ TIMELINE.forEach(seg => {
753
+ if (!seg.label) return;
754
+ const rowIdx = labels.indexOf(seg.label);
755
+ const x = leftPad + (seg.start / DURATION) * plotW;
756
+ const w = Math.max(2, ((seg.end - seg.start) / DURATION) * plotW);
757
+ const y = topPad + rowIdx * rowH + 3;
758
+ ctx.fillStyle = colors[rowIdx % colors.length];
759
+ const rr = Math.min(4, (rowH - 6) / 2, w / 2);
760
+ ctx.beginPath();
761
+ ctx.moveTo(x + rr, y);
762
+ ctx.arcTo(x + w, y, x + w, y + (rowH - 6), rr);
763
+ ctx.arcTo(x + w, y + (rowH - 6), x, y + (rowH - 6), rr);
764
+ ctx.arcTo(x, y + (rowH - 6), x, y, rr);
765
+ ctx.arcTo(x, y, x + w, y, rr);
766
+ ctx.closePath();
767
+ ctx.fill();
768
+ });
769
 
770
+ ctx.strokeStyle = '#302a3d55';
771
+ ctx.beginPath();
772
+ ctx.moveTo(leftPad, cv.height - 18);
773
+ ctx.lineTo(cv.width - 10, cv.height - 18);
774
+ ctx.stroke();
775
+ ctx.fillStyle = dim;
776
+ [0, 0.25, 0.5, 0.75, 1].forEach(f => {
777
+ const x = leftPad + f * plotW;
778
+ ctx.fillText((f * DURATION).toFixed(1) + 's', x, cv.height - 4);
779
+ });
780
+ }
781
+
782
+ paintTimeline();
783
+ window.insightuxRepaintCanvases.push(paintTimeline);
784
+
785
+ const leftPadForHit = 220, topPadForHit = 10;
786
+ cv.addEventListener('mousemove', function(e){
787
+ cv.title = '';
788
+ if (!TIMELINE.length || DURATION <= 0) return;
789
+ const rect = cv.getBoundingClientRect();
790
+ const scaleX = cv.width / rect.width;
791
+ const mx = (e.clientX - rect.left) * scaleX;
792
+ const plotW = cv.width - leftPadForHit - 20;
793
+ if (mx < leftPadForHit) return;
794
+ const t = ((mx - leftPadForHit) / plotW) * DURATION;
795
+ const hit = TIMELINE.find(s => s.label && t >= s.start && t <= s.end);
796
+ if (hit) cv.title = hit.label + ' — ' + hit.start.toFixed(1) + 's to ' + hit.end.toFixed(1) + 's';
797
  });
798
  })();
799
  </script>
 
811
  segments = build_screenshot_segments(gaze, dom)
812
  summary = session_summary(gaze, dom)
813
  mouse = summarize_mouse(session_dir)
814
+ scroll_depth = compute_scroll_depth(dom)
815
 
816
  top_label = ranking[0]["label"] if ranking else "—"
817
 
 
821
  html = html.replace("__DURATION__", str(summary["duration"]))
822
  html = html.replace("__NUM_ELEMENTS__", str(len(ranking)))
823
  html = html.replace("__TOP_LABEL__", top_label)
824
+ html = html.replace("__CLICK_COUNT__", str(mouse["click_count"]))
825
+ html = html.replace("__SCROLL_DEPTH__", str(scroll_depth))
826
  html = html.replace("__RANKING_JSON__", json.dumps(ranking))
827
  html = html.replace("__TIMELINE_JSON__", json.dumps(timeline))
828
  html = html.replace("__SEGMENTS_JSON__", json.dumps(segments))
 
830
  html = html.replace("__MOUSE_INTERESTS_JSON__", json.dumps(mouse["interests"]))
831
  html = html.replace("__MOUSE_CLICKS_JSON__", json.dumps(mouse["clicks"]))
832
 
833
+ html = html.replace("__THEME_CSS__", theme.THEME_CSS)
834
+ html = html.replace("__THEME_JS__", theme.THEME_TOGGLE_JS)
835
+ html = html.replace("__ICONS_JS__", theme.ICONS_JS)
836
+ html = html.replace("__EYE_ICON__", theme.icon("eye", 18))
837
+ html = html.replace("__FILTER_ICON__", theme.icon("sliders", 13))
838
+ html = html.replace("__COPY_ICON__", theme.icon("type", 13))
839
+ html = html.replace("__EXPORT_ICON__", theme.icon("download", 13))
840
+ html = html.replace("__CLOCK_ICON__", theme.icon("clock", 15))
841
+ html = html.replace("__LAYERS_ICON__", theme.icon("layers", 15))
842
+ html = html.replace("__TARGET_ICON__", theme.icon("target", 15))
843
+ html = html.replace("__CURSOR_ICON__", theme.icon("cursor", 15))
844
+ html = html.replace("__CHEVRON_ICON__", theme.icon("chevron-down", 15))
845
+ html = html.replace("__TYPE_ICON__", theme.icon("type", 13))
846
+ html = html.replace("__INFO_ICON__", theme.icon("info", 13))
847
+ html = html.replace("__CLICK_ICON__", theme.icon("click", 13))
848
+ html = html.replace("__MAXIMIZE_ICON__", theme.icon("maximize", 13))
849
+ html = html.replace("__DOWNLOAD_ICON__", theme.icon("download", 13))
850
+
851
  out_path = os.path.join(session_dir, "analysis_report.html")
852
  with open(out_path, "w", encoding="utf-8") as f:
853
  f.write(html)
 
859
  import sys
860
  session_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.join("sessions", "live")
861
  path = generate_report(session_dir)
862
+ print(f"Report written to: {path}")
browser_session.py CHANGED
@@ -61,6 +61,7 @@ from preprocessing.preprocessing_pipeline import (
61
  from inference_pipeline import InsightUXPipeline, GazeAngleSmoother
62
  from session_logger import GazeLogger
63
  from analysis import generate_report
 
64
 
65
 
66
  # =============================================================================
@@ -119,48 +120,124 @@ _LANDING_HTML = r"""<!DOCTYPE html>
119
  <meta name="insightux-landing" content="true">
120
  <title>InsightUX — Eye-Tracking Research Browser</title>
121
  <style>
 
122
  * { box-sizing: border-box; }
 
123
  body {
124
- margin: 0; height: 100vh; display: flex; flex-direction: column;
125
- align-items: center; justify-content: center;
126
- background: radial-gradient(circle at 50% 35%, #221a2e, #100e16 70%);
127
- font-family: -apple-system, 'Segoe UI', Arial, sans-serif; color: #f0ecf7;
 
 
 
 
128
  }
 
 
 
 
 
 
 
 
 
 
129
  .logo {
130
- font-size: 42px; font-weight: 700; letter-spacing: -0.01em;
131
- background: linear-gradient(90deg, #9B59FF, #FF2DF0);
132
- -webkit-background-clip: text; -webkit-text-fill-color: transparent;
133
- margin-bottom: 6px;
 
 
 
 
 
 
 
134
  }
135
- .tagline { color: #8a8098; font-size: 14px; margin-bottom: 36px; }
136
- form { width: 560px; max-width: 88vw; }
137
  input {
138
- width: 100%; padding: 15px 20px; font-size: 15px; border-radius: 26px;
139
- border: 1.5px solid #3a3348; background: #1c1826; color: #f0ecf7;
140
- outline: none; transition: border-color 0.15s;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  }
142
- input:focus { border-color: #9B59FF; }
143
- input::placeholder { color: #635a72; }
 
 
 
 
 
 
 
144
  .hint {
145
- margin-top: 18px; font-size: 12px; color: #6b6278; text-align: center;
146
- line-height: 1.6;
147
  }
148
- .hint b { color: #9B59FF; }
149
  </style>
150
  </head>
151
  <body>
152
- <div class="logo">InsightUX</div>
153
- <div class="tagline">Eye-Tracking Research Browser</div>
154
- <form id="searchForm">
 
 
 
 
 
 
 
 
 
 
155
  <input id="searchInput" type="text" autofocus
156
  placeholder="Search Google, or enter a website address">
157
  </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  <div class="hint">
159
- Search results and websites open below as normal.<br>
160
- Once you land on the page you want to study, press <b>S</b> to start eye-tracking,
161
- <b>E</b> to stop.
162
  </div>
 
163
  <script>
 
 
 
 
 
 
 
 
164
  document.getElementById('searchForm').addEventListener('submit', function(e){
165
  e.preventDefault();
166
  const raw = document.getElementById('searchInput').value.trim();
@@ -175,16 +252,62 @@ _LANDING_HTML = r"""<!DOCTYPE html>
175
  window.location.href = 'https://www.google.com/search?q=' + encodeURIComponent(raw);
176
  }
177
  });
 
 
 
 
 
 
 
 
 
 
 
178
  </script>
179
  </body>
180
  </html>
181
  """
182
 
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  def _write_landing_page():
185
  path = os.path.join(DATA_DIR, "insightux_landing.html")
 
 
 
 
 
 
 
 
 
 
 
186
  with open(path, "w", encoding="utf-8") as f:
187
- f.write(_LANDING_HTML)
188
  return "file://" + path.replace(os.sep, "/")
189
 
190
 
@@ -277,91 +400,153 @@ CHROME_JS = r"""
277
  const isReport = !!document.querySelector('meta[name="insightux-report"]');
278
  const isTrackable = !isLanding && !isReport;
279
 
 
 
 
280
  const style = document.createElement('style');
281
  style.textContent = `
282
- html {
283
- overflow-x: hidden !important;
284
- }
285
  body {
286
  box-sizing: border-box !important;
287
  width: 100vw !important;
288
  margin: 0 !important;
289
- padding-top: 46px !important;
290
- padding-left: 84px !important;
291
  overflow-x: hidden !important;
292
  }
293
  #__insightux_toolbar {
294
- position:fixed; top:0; left:0; right:0; height:46px; z-index:2147483647;
295
- display:flex; align-items:center; gap:10px; padding:0 12px; box-sizing:border-box;
296
- background:linear-gradient(90deg, rgba(20,18,26,0.97), rgba(30,20,40,0.97));
297
- border-bottom:2px solid; border-image:linear-gradient(90deg,#7B2FBE,#FF2DF0) 1;
298
- box-shadow:0 3px 14px rgba(0,0,0,0.35);
299
- font:12px -apple-system,'Segoe UI',Arial; color:#f0ecf7;
300
  }
301
- #__insightux_toolbar button {
302
- flex:0 0 auto; width:30px; height:30px; border-radius:8px; cursor:pointer;
303
- background:#241f2e; border:1px solid #3a3348; color:#f0ecf7; font-size:15px;
304
- display:flex; align-items:center; justify-content:center;
305
  }
306
- #__insightux_toolbar button:hover { background:#302a3d; }
 
 
 
307
  #__iux_addr_form { flex:1 1 auto; min-width:0; }
308
  #__iux_addr {
309
- width:100%; box-sizing:border-box; padding:7px 14px; border-radius:16px;
310
- border:1.5px solid #3a3348; background:#1c1826; color:#f0ecf7; outline:none;
311
- font-size:12.5px;
312
  }
313
- #__iux_addr:focus { border-color:#9B59FF; }
314
- #__insightux_status {
315
- flex:0 0 auto; color:#c9a6f5; white-space:nowrap; max-width:30vw;
316
- overflow:hidden; text-overflow:ellipsis;
317
  }
 
 
 
 
 
318
  #__insightux_sidebar {
319
- position:fixed; top:46px; left:0; bottom:0; width:84px; z-index:2147483647;
320
- display:flex; flex-direction:column; align-items:stretch; gap:8px; padding:10px 8px;
321
- box-sizing:border-box; font:11px -apple-system,'Segoe UI',Arial;
322
- background:linear-gradient(180deg, rgba(24,20,32,0.97), rgba(16,14,22,0.97));
323
- border-right:2px solid; border-image:linear-gradient(180deg,#7B2FBE,#FF2DF0) 1;
324
- box-shadow:3px 0 14px rgba(0,0,0,0.35);
 
 
 
 
 
 
 
325
  }
326
- #__insightux_sidebar button {
327
- background:#241f2e; border:1px solid #3a3348; color:#f0ecf7; border-radius:10px;
328
- padding:10px 4px; cursor:pointer; line-height:1.5; font-size:11px; text-align:center;
 
 
 
329
  }
330
- #__insightux_sidebar button:hover { background:#302a3d; }
331
- #__iux_session.active { background:linear-gradient(135deg,#7B2FBE,#FF2DF0); border-color:transparent; }
332
- #__insightux_sidebar .__iux_hints {
333
- margin-top:auto; color:#7a7288; font-size:9.5px; line-height:1.6; text-align:center;
334
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  #__insightux_update {
336
  display:none; cursor:pointer; flex:0 0 auto; white-space:nowrap;
337
- padding:5px 12px; border-radius:12px; font-weight:600; font-size:11px;
338
- background:linear-gradient(135deg,#7B2FBE,#FF2DF0); color:#fff;
339
  }
340
- #__insightux_update:hover { filter:brightness(1.12); }
341
  `;
342
  document.head.appendChild(style);
343
 
 
 
 
 
 
 
 
 
 
344
  const toolbar = document.createElement('div');
345
  toolbar.id = '__insightux_toolbar';
 
346
  toolbar.innerHTML = `
347
- <button type="button" id="__iux_back" title="Back">&#8592;</button>
348
- <button type="button" id="__iux_fwd" title="Forward">&#8594;</button>
349
- <button type="button" id="__iux_reload" title="Reload">&#8635;</button>
350
- <button type="button" id="__iux_home" title="Home">&#8962;</button>
351
- <form id="__iux_addr_form"><input id="__iux_addr" type="text" autocomplete="off"
352
- placeholder="Search or enter address"></form>
 
 
 
 
 
 
 
 
353
  <span id="__insightux_update" title="Click to open the download page"></span>
354
- <span id="__insightux_status">Press S to start tracking &middot; E to stop &middot; H heatmap &middot; M mouse panel &middot; X quit</span>
 
355
  `;
356
  document.documentElement.appendChild(toolbar);
357
 
358
  const sidebar = document.createElement('div');
359
  sidebar.id = '__insightux_sidebar';
 
360
  sidebar.innerHTML = `
361
- <button type="button" id="__iux_calibrate">&#128065;<br>Calibrate</button>
362
- <button type="button" id="__iux_validate">&#127919;<br>Validate</button>
363
- <button type="button" id="__iux_session">&#9654;<br>Start<br>Session</button>
364
- <div class="__iux_hints">S/E start&#47;stop<br>H heatmap<br>M panel<br>X quit</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  `;
366
  document.documentElement.appendChild(sidebar);
367
 
@@ -370,22 +555,83 @@ CHROME_JS = r"""
370
  if (!document.documentElement.contains(sidebar)) document.documentElement.appendChild(sidebar);
371
  }, 1000);
372
 
373
- const statusEl = () => document.getElementById('__insightux_status');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  window.insightuxSetStatus = function(text){
375
  const el = statusEl();
376
  if (el) el.textContent = text;
377
  };
 
 
 
 
 
 
 
 
 
 
 
378
  window.insightuxSetSessionUI = function(isTracking){
379
  const btn = document.getElementById('__iux_session');
380
- if (!btn) return;
381
- btn.innerHTML = isTracking ? '&#9632;<br>Stop<br>Session' : '&#9654;<br>Start<br>Session';
382
- btn.classList.toggle('active', isTracking);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  };
384
  window.insightuxShowUpdate = function(version, url){
385
  const el = document.getElementById('__insightux_update');
386
  if (!el) return;
387
- el.textContent = 'Update available (v' + version + ')';
388
- el.style.display = 'inline-block';
389
  el.onclick = function(){
390
  if (window.pywebview && window.pywebview.api && window.pywebview.api.open_update_page) {
391
  window.pywebview.api.open_update_page(url);
@@ -463,6 +709,10 @@ CHROME_JS = r"""
463
  })();
464
  """
465
  CHROME_JS = CHROME_JS.replace("__INSIGHTUX_LANDING_URL__", json.dumps(LANDING_URL))
 
 
 
 
466
 
467
  # JS: tracking overlay (gaze dot + AOI highlighting) — injected only once
468
  # tracking actually starts. Ported directly from run_session.py's JS_SETUP.
@@ -493,6 +743,16 @@ TRACKING_JS = r"""
493
  if (!document.body.contains(cv)) document.body.appendChild(cv);
494
  }, 1000);
495
 
 
 
 
 
 
 
 
 
 
 
496
  const MIN_W=40, MIN_H=24, MAX_AOIS=90, MAX_SCAN=2500;
497
  const PAD_PX = 90;
498
  const TALL_WRAPPER_H = 220;
@@ -653,30 +913,55 @@ TRACKING_JS = r"""
653
  ctx.clearRect(0,0,cv.width,cv.height);
654
 
655
  if (activeBox){
656
- ctx.fillStyle = 'rgba(123,47,190,0.16)';
657
- ctx.fillRect(activeBox.x, activeBox.y, activeBox.w, activeBox.h);
658
- ctx.strokeStyle = '#7B2FBE'; ctx.lineWidth = 3;
659
- ctx.strokeRect(activeBox.x, activeBox.y, activeBox.w, activeBox.h);
 
 
 
 
 
 
660
  const label = activeBox.label;
661
- ctx.font = 'bold 13px Arial';
662
- const tw = ctx.measureText(label).width + 16;
663
- const ly = Math.max(0, activeBox.y - 22);
664
- ctx.fillStyle = '#7B2FBE';
665
- ctx.fillRect(activeBox.x, ly, tw, 20);
 
 
 
 
666
  ctx.fillStyle = '#FFFFFF';
667
- ctx.fillText(label, activeBox.x + 8, ly + 14);
 
668
  }
669
 
 
 
670
  ctx.beginPath();
671
- ctx.arc(dot.x, dot.y, 13, 0, 2*Math.PI);
672
- ctx.fillStyle = 'rgba(255,255,255,0.9)';
 
 
 
 
 
 
 
 
673
  ctx.fill();
 
 
674
  ctx.beginPath();
675
- ctx.arc(dot.x, dot.y, 9, 0, 2*Math.PI);
676
- ctx.fillStyle = '#FF2DF0';
 
 
677
  ctx.fill();
678
- ctx.lineWidth = 2;
679
- ctx.strokeStyle = 'rgba(0,0,0,0.55)';
680
  ctx.stroke();
681
 
682
  requestAnimationFrame(renderLoop);
@@ -927,6 +1212,7 @@ MOUSE_JS = r"""
927
  }
928
 
929
  // ---- heatmap overlay canvas (ported 1:1 from the extension) ----
 
930
  window.insightuxMouseToggleHeatmap = function(){
931
  if (canvas) { document.body.removeChild(canvas); canvas = null; return; }
932
  canvas = document.createElement('canvas');
@@ -934,11 +1220,17 @@ MOUSE_JS = r"""
934
  canvas.style.top = '0'; canvas.style.left = '0';
935
  canvas.style.zIndex = '2147483645';
936
  canvas.style.pointerEvents = 'none';
 
 
937
  canvas.width = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, document.documentElement.offsetWidth);
938
  canvas.height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, document.documentElement.offsetHeight);
939
  document.body.appendChild(canvas);
940
  drawHeatmapHighQuality(allHeatmap, allTrail, allClicks);
941
  };
 
 
 
 
942
 
943
  function drawClick(x, y){
944
  if (!canvas) return;
@@ -971,7 +1263,7 @@ MOUSE_JS = r"""
971
  brushCanvas.width = radius * 2; brushCanvas.height = radius * 2;
972
  const brushCtx = brushCanvas.getContext('2d');
973
  const g = brushCtx.createRadialGradient(radius, radius, 0, radius, radius, radius);
974
- g.addColorStop(0, 'rgba(0, 0, 0, 0.05)'); g.addColorStop(1, 'rgba(0, 0, 0, 0)');
975
  brushCtx.fillStyle = g; brushCtx.fillRect(0, 0, radius * 2, radius * 2);
976
  allHeatPoints.forEach(function(point){ ctx.drawImage(brushCanvas, point.x - radius, point.y - radius); });
977
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
@@ -1012,48 +1304,61 @@ MOUSE_JS = r"""
1012
  });
1013
 
1014
  // ---- insights panel: in-page port of the extension's popup.html/popup.js ----
 
1015
  const style = document.createElement('style');
1016
  style.textContent = `
 
1017
  #__insightux_mouse_panel {
1018
- position: fixed; top: 44px; right: 16px; width: 260px; z-index: 2147483647;
1019
- background: linear-gradient(180deg, rgba(28,24,38,0.97), rgba(20,18,26,0.97));
1020
- border: 1px solid #3a3348; border-radius: 12px; box-shadow: 0 8px 28px rgba(0,0,0,0.45);
1021
- font: 12px -apple-system, 'Segoe UI', Arial; color: #f0ecf7; padding: 14px;
1022
  pointer-events: auto; display: none;
 
1023
  }
1024
  #__insightux_mouse_panel.open { display: block; }
1025
- #__insightux_mouse_panel h3 { margin: 0 0 8px 0; font-size: 13px; color: #f0ecf7;
1026
- display:flex; align-items:center; justify-content:space-between; }
1027
- #__insightux_mouse_panel .muted { color: #9a92ad; font-size: 11px; }
1028
- #__insightux_mouse_panel .row { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
1029
- #__insightux_mouse_panel button {
1030
- flex: 1 1 auto; padding: 6px 8px; font-size: 11px; border-radius: 6px; border: 1px solid #3a3348;
1031
- background: #241f2e; color: #f0ecf7; cursor: pointer;
1032
  }
1033
- #__insightux_mouse_panel button:hover { background: #302a3d; }
1034
- #__insightux_mouse_panel .timer { font-weight: 600; margin-bottom: 6px; }
1035
  #__insightux_mouse_panel .hero {
1036
- background: rgba(123,47,190,0.15); border: 1px solid #7B2FBE; border-radius: 8px;
1037
- padding: 8px; text-align: center; margin-bottom: 8px;
 
1038
  }
1039
- #__insightux_mouse_panel .hero .lbl { font-size: 9px; text-transform: uppercase; color: #c9a6f5; letter-spacing: 0.05em; }
1040
- #__insightux_mouse_panel .hero .el { font-weight: 600; margin: 3px 0; color: #FF2DF0; }
1041
  #__insightux_mouse_panel .interest-item, #__insightux_mouse_panel .log-item {
1042
- background: #241f2e; border-radius: 6px; padding: 5px 7px; margin-bottom: 4px; font-size: 11px;
1043
  }
1044
  #__insightux_mouse_panel .list { max-height: 160px; overflow-y: auto; }
 
 
 
 
 
1045
  `;
1046
  document.head.appendChild(style);
1047
 
1048
  const panel = document.createElement('div');
1049
  panel.id = '__insightux_mouse_panel';
 
1050
  panel.innerHTML = `
1051
- <h3>Mouse Tracker <span class="muted" id="__mt_status">idle</span></h3>
1052
- <div class="timer" id="__mt_timer">Session Time: 00:00</div>
1053
  <div class="row">
1054
- <button id="__mt_clear">Clear</button>
1055
- <button id="__mt_heatmap">Heatmap</button>
1056
- <button id="__mt_logs">Logs</button>
 
 
 
 
1057
  </div>
1058
  <div id="__mt_hero" class="hero" style="display:none;">
1059
  <div class="lbl">Most Viewed Element</div>
@@ -1064,6 +1369,7 @@ MOUSE_JS = r"""
1064
  <div id="__mt_logs_list" class="list" style="display:none;"></div>
1065
  `;
1066
  document.documentElement.appendChild(panel);
 
1067
 
1068
  document.getElementById('__mt_clear').addEventListener('click', function(){
1069
  trailBuffer = []; heatmapBuffer = []; clickBuffer = []; dwellBuffer = [];
@@ -1072,9 +1378,22 @@ MOUSE_JS = r"""
1072
  if (canvas) { document.body.removeChild(canvas); canvas = null; }
1073
  updatePanel();
1074
  });
1075
- document.getElementById('__mt_heatmap').addEventListener('click', function(){ window.insightuxMouseToggleHeatmap(); });
 
 
 
 
 
 
 
 
 
 
 
 
1076
  document.getElementById('__mt_logs').addEventListener('click', function(){
1077
  logsOpen = !logsOpen;
 
1078
  document.getElementById('__mt_logs_list').style.display = logsOpen ? 'block' : 'none';
1079
  document.getElementById('__mt_interests').style.display = logsOpen ? 'none' : 'block';
1080
  updatePanel();
@@ -1150,6 +1469,8 @@ MOUSE_JS = r"""
1150
  updatePanel();
1151
  })();
1152
  """
 
 
1153
 
1154
 
1155
  # =============================================================================
 
61
  from inference_pipeline import InsightUXPipeline, GazeAngleSmoother
62
  from session_logger import GazeLogger
63
  from analysis import generate_report
64
+ import theme
65
 
66
 
67
  # =============================================================================
 
120
  <meta name="insightux-landing" content="true">
121
  <title>InsightUX — Eye-Tracking Research Browser</title>
122
  <style>
123
+ __THEME_CSS__
124
  * { box-sizing: border-box; }
125
+ html, body { height: 100%; }
126
  body {
127
+ margin: 0; display: flex; flex-direction: column;
128
+ align-items: center; justify-content: center; gap: 26px;
129
+ background:
130
+ radial-gradient(circle at 18% 10%, color-mix(in srgb, var(--iux-primary) 20%, transparent), transparent 42%),
131
+ radial-gradient(circle at 82% 82%, color-mix(in srgb, var(--iux-indigo) 18%, transparent), transparent 45%),
132
+ var(--iux-bg);
133
+ font-family: var(--iux-font); color: var(--iux-text);
134
+ transition: background 0.3s var(--iux-ease), color 0.3s var(--iux-ease);
135
  }
136
+ .theme-toggle {
137
+ position: fixed; top: 20px; right: 22px; width: 38px; height: 38px; padding: 0;
138
+ }
139
+ .brand { text-align: center; }
140
+ .logo-row { display: flex; align-items: center; justify-content: center; gap: 12px; }
141
+ .logo-mark {
142
+ width: 46px; height: 46px; border-radius: 14px; background: var(--iux-accent-grad);
143
+ display: flex; align-items: center; justify-content: center; box-shadow: var(--iux-shadow-glow);
144
+ }
145
+ .logo-mark svg { color: #fff; }
146
  .logo {
147
+ font-size: 40px; font-weight: 700; letter-spacing: -0.02em;
148
+ background: var(--iux-accent-grad);
149
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
150
+ }
151
+ .tagline { color: var(--iux-text-dim); font-size: 14.5px; margin-top: 8px; }
152
+ .desc { color: var(--iux-text-faint); font-size: 12.5px; margin-top: 4px; max-width: 480px; }
153
+
154
+ form { width: 560px; max-width: 88vw; position: relative; }
155
+ form .search-icon {
156
+ position: absolute; left: 18px; top: 50%; transform: translateY(-50%); color: var(--iux-text-faint);
157
+ pointer-events: none;
158
  }
 
 
159
  input {
160
+ width: 100%; padding: 16px 20px 16px 46px; font-size: 15px; border-radius: 28px;
161
+ border: 1.5px solid var(--iux-border); background: var(--iux-surface); color: var(--iux-text);
162
+ outline: none; transition: border-color 0.15s var(--iux-ease), box-shadow 0.15s var(--iux-ease);
163
+ box-shadow: var(--iux-shadow-sm);
164
+ }
165
+ input:focus { border-color: var(--iux-primary-light); box-shadow: var(--iux-shadow-glow); }
166
+ input::placeholder { color: var(--iux-text-faint); }
167
+
168
+ .chips { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; max-width: 640px; }
169
+ .chip {
170
+ display: flex; align-items: center; gap: 8px; padding: 8px 14px; border-radius: 999px;
171
+ background: var(--iux-surface); border: 1px solid var(--iux-border); font-size: 12px; color: var(--iux-text-dim);
172
+ }
173
+ .chip kbd {
174
+ background: var(--iux-surface-hi); border: 1px solid var(--iux-border); border-radius: 6px;
175
+ padding: 1px 7px; font: 700 11px var(--iux-font); color: var(--iux-primary-light);
176
+ }
177
+
178
+ .recent { width: 640px; max-width: 90vw; margin-top: 4px; }
179
+ .recent h3 {
180
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--iux-text-faint);
181
+ margin: 0 0 10px 2px; font-weight: 600;
182
  }
183
+ .recent-list { display: flex; flex-direction: column; gap: 6px; }
184
+ .recent-item {
185
+ display: flex; align-items: center; gap: 10px; padding: 10px 14px; cursor: pointer;
186
+ color: var(--iux-text-dim); font-size: 12.5px;
187
+ }
188
+ .recent-item:hover { background: var(--iux-surface-hi); }
189
+ .recent-item .rt { color: var(--iux-text-faint); margin-left: auto; font-size: 11px; }
190
+ .recent-empty { color: var(--iux-text-faint); font-size: 12px; text-align: center; padding: 14px 0; }
191
+
192
  .hint {
193
+ color: var(--iux-text-faint); text-align: center; line-height: 1.6; font-size: 11.5px; max-width: 520px;
 
194
  }
 
195
  </style>
196
  </head>
197
  <body>
198
+ <button type="button" class="theme-toggle iux-btn" id="themeToggle" title="Toggle theme"></button>
199
+
200
+ <div class="brand iux-fade-in">
201
+ <div class="logo-row">
202
+ <div class="logo-mark">__LOGO_ICON__</div>
203
+ <div class="logo">InsightUX</div>
204
+ </div>
205
+ <div class="tagline">AI-Powered Eye &amp; Mouse Tracking Research Browser</div>
206
+ <div class="desc">Browse the real web, then press S to start studying attention — gaze, cursor, and clicks, all in one session.</div>
207
+ </div>
208
+
209
+ <form id="searchForm" class="iux-fade-in">
210
+ <span class="search-icon">__SEARCH_ICON__</span>
211
  <input id="searchInput" type="text" autofocus
212
  placeholder="Search Google, or enter a website address">
213
  </form>
214
+
215
+ <div class="chips iux-fade-in">
216
+ <span class="chip">__PLAY_ICON__ <kbd>S</kbd> Start Tracking</span>
217
+ <span class="chip">__STOP_ICON__ <kbd>E</kbd> Stop Tracking</span>
218
+ <span class="chip">__LAYERS_ICON__ <kbd>H</kbd> Heatmap</span>
219
+ <span class="chip">__CURSOR_ICON__ <kbd>M</kbd> Mouse Panel</span>
220
+ </div>
221
+
222
+ <div class="recent iux-card iux-fade-in" style="padding: 16px 10px;">
223
+ <h3 style="padding-left: 10px;">Recent Sessions</h3>
224
+ <div class="recent-list" id="recentList"></div>
225
+ </div>
226
+
227
  <div class="hint">
228
+ Search results and websites open below as normal. Once you land on the page you want
229
+ to study, press <b>S</b> to start eye-tracking, <b>E</b> to stop.
 
230
  </div>
231
+
232
  <script>
233
+ __ICONS_JS__
234
+ __THEME_JS__
235
+ document.getElementById('themeToggle').innerHTML = iuxIcon(window.insightuxGetTheme() === 'light' ? 'moon' : 'sun', 18);
236
+ document.getElementById('themeToggle').addEventListener('click', function(){
237
+ const next = window.insightuxToggleTheme();
238
+ this.innerHTML = iuxIcon(next === 'light' ? 'moon' : 'sun', 18);
239
+ });
240
+
241
  document.getElementById('searchForm').addEventListener('submit', function(e){
242
  e.preventDefault();
243
  const raw = document.getElementById('searchInput').value.trim();
 
252
  window.location.href = 'https://www.google.com/search?q=' + encodeURIComponent(raw);
253
  }
254
  });
255
+
256
+ const RECENT_SESSIONS = __RECENT_SESSIONS_JSON__;
257
+ const recentList = document.getElementById('recentList');
258
+ if (!RECENT_SESSIONS.length) {
259
+ recentList.innerHTML = '<div class="recent-empty">No sessions yet — start tracking on any page to create your first one.</div>';
260
+ } else {
261
+ recentList.innerHTML = RECENT_SESSIONS.map(function(s){
262
+ return '<div class="recent-item">' + iuxIcon('clock', 15) + '<span>' + s.label + '</span>' +
263
+ '<span class="rt">' + s.when + '</span></div>';
264
+ }).join('');
265
+ }
266
  </script>
267
  </body>
268
  </html>
269
  """
270
 
271
 
272
+ def _recent_sessions_json(limit=5):
273
+ """Read-only directory listing of sessions/<timestamp> folders (already
274
+ a well-known constant, SESSIONS_ROOT) for the landing page's "Recent
275
+ Sessions" strip. Never touches session contents or tracking state."""
276
+ import datetime as _dt
277
+ entries = []
278
+ if os.path.isdir(SESSIONS_ROOT):
279
+ for name in os.listdir(SESSIONS_ROOT):
280
+ full = os.path.join(SESSIONS_ROOT, name)
281
+ if not os.path.isdir(full):
282
+ continue
283
+ try:
284
+ ts = _dt.datetime.strptime(name, "%Y%m%d_%H%M%S")
285
+ except ValueError:
286
+ continue
287
+ entries.append((ts, name))
288
+ entries.sort(key=lambda pair: pair[0], reverse=True)
289
+ return [
290
+ {"label": ts.strftime("%b %d, %Y — %I:%M %p").replace(" 0", " "),
291
+ "when": ts.strftime("%Y-%m-%d %H:%M")}
292
+ for ts, _name in entries[:limit]
293
+ ]
294
+
295
+
296
  def _write_landing_page():
297
  path = os.path.join(DATA_DIR, "insightux_landing.html")
298
+ html = _LANDING_HTML
299
+ html = html.replace("__THEME_CSS__", theme.THEME_CSS)
300
+ html = html.replace("__THEME_JS__", theme.THEME_TOGGLE_JS)
301
+ html = html.replace("__ICONS_JS__", theme.ICONS_JS)
302
+ html = html.replace("__LOGO_ICON__", theme.icon("eye", 24))
303
+ html = html.replace("__SEARCH_ICON__", theme.icon("search", 18))
304
+ html = html.replace("__PLAY_ICON__", theme.icon("play", 13))
305
+ html = html.replace("__STOP_ICON__", theme.icon("stop", 13))
306
+ html = html.replace("__LAYERS_ICON__", theme.icon("layers", 13))
307
+ html = html.replace("__CURSOR_ICON__", theme.icon("cursor", 13))
308
+ html = html.replace("__RECENT_SESSIONS_JSON__", json.dumps(_recent_sessions_json()))
309
  with open(path, "w", encoding="utf-8") as f:
310
+ f.write(html)
311
  return "file://" + path.replace(os.sep, "/")
312
 
313
 
 
400
  const isReport = !!document.querySelector('meta[name="insightux-report"]');
401
  const isTrackable = !isLanding && !isReport;
402
 
403
+ __ICONS_JS__
404
+ __THEME_JS__
405
+
406
  const style = document.createElement('style');
407
  style.textContent = `
408
+ __THEME_CSS__
409
+ html { overflow-x: hidden !important; }
 
410
  body {
411
  box-sizing: border-box !important;
412
  width: 100vw !important;
413
  margin: 0 !important;
414
+ padding-top: 54px !important;
415
+ padding-left: 78px !important;
416
  overflow-x: hidden !important;
417
  }
418
  #__insightux_toolbar {
419
+ position:fixed; top:0; left:0; right:0; height:54px; z-index:2147483647;
420
+ display:flex; align-items:center; gap:8px; padding:0 14px; box-sizing:border-box;
421
+ font:12px var(--iux-font); color:var(--iux-text);
422
+ border-bottom:1px solid var(--iux-border);
 
 
423
  }
424
+ #__insightux_toolbar .iux-navbtn {
425
+ flex:0 0 auto; width:32px; height:32px; border-radius:10px; padding:0;
 
 
426
  }
427
+ #__iux_addr_wrap {
428
+ flex:1 1 auto; min-width:0; position:relative; display:flex; align-items:center;
429
+ }
430
+ #__iux_addr_wrap .lead-icon { position:absolute; left:12px; color:var(--iux-text-faint); pointer-events:none; display:flex; }
431
  #__iux_addr_form { flex:1 1 auto; min-width:0; }
432
  #__iux_addr {
433
+ width:100%; box-sizing:border-box; padding:8px 14px 8px 36px; border-radius:999px;
434
+ border:1.5px solid var(--iux-border); background:var(--iux-surface); color:var(--iux-text); outline:none;
435
+ font-size:12.5px; transition:border-color .15s var(--iux-ease), box-shadow .15s var(--iux-ease);
436
  }
437
+ #__iux_addr:focus { border-color:var(--iux-primary-light); box-shadow:var(--iux-shadow-glow); }
438
+ #__iux_loadbar {
439
+ position:fixed; top:0; left:0; height:2px; width:0; z-index:2147483647;
440
+ background:var(--iux-accent-grad); transition:width .5s var(--iux-ease), opacity .3s ease .25s; opacity:0;
441
  }
442
+ #__insightux_indicators { display:flex; align-items:center; gap:6px; flex:0 0 auto; }
443
+ #__insightux_status.iux-pill { max-width:26vw; }
444
+ #__insightux_status .txt { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
445
+ #__iux_theme_toggle { flex:0 0 auto; }
446
+
447
  #__insightux_sidebar {
448
+ position:fixed; top:66px; left:12px; bottom:12px; width:66px; z-index:2147483647;
449
+ display:flex; flex-direction:column; align-items:stretch; gap:8px; padding:12px 8px;
450
+ box-sizing:border-box; font:11px var(--iux-font);
451
+ border-radius:var(--iux-radius-lg);
452
+ transition:width .2s var(--iux-ease);
453
+ }
454
+ #__insightux_sidebar.collapsed { width:56px; }
455
+ #__insightux_sidebar .side-btn {
456
+ position:relative; flex-direction:column; gap:4px; padding:11px 4px; line-height:1.35;
457
+ font-size:10.5px; width:100%;
458
+ }
459
+ #__insightux_sidebar .side-btn.active {
460
+ background:var(--iux-accent-grad); border-color:transparent; color:#fff; box-shadow:var(--iux-shadow-glow);
461
  }
462
+ #__insightux_sidebar.collapsed .side-btn .lbl { display:none; }
463
+ #__insightux_sidebar .side-btn .tip {
464
+ position:absolute; left:100%; top:50%; transform:translateY(-50%) translateX(8px);
465
+ background:var(--iux-surface-hi); color:var(--iux-text); border:1px solid var(--iux-border);
466
+ padding:5px 10px; border-radius:8px; font-size:11px; white-space:nowrap; pointer-events:none;
467
+ opacity:0; transition:opacity .15s ease; box-shadow:var(--iux-shadow-sm);
468
  }
469
+ #__insightux_sidebar.collapsed .side-btn:hover .tip { opacity:1; }
470
+ #__insightux_sidebar .spacer { margin-top:auto; }
471
+ #__insightux_sidebar .hints {
472
+ color:var(--iux-text-faint); font-size:9px; line-height:1.7; text-align:center; padding:2px 0;
473
  }
474
+ #__insightux_sidebar.collapsed .hints { display:none; }
475
+ #__iux_collapse_btn { width:100%; }
476
+
477
+ #__iux_settings_pop {
478
+ position:absolute; left:100%; bottom:0; margin-left:10px; width:220px; padding:14px;
479
+ display:none; z-index:2147483647; border-radius:var(--iux-radius);
480
+ }
481
+ #__iux_settings_pop.open { display:block; }
482
+ #__iux_settings_pop h4 { margin:0 0 10px; font-size:12px; color:var(--iux-text); }
483
+ #__iux_settings_pop .row { display:flex; align-items:center; justify-content:space-between; margin-bottom:8px; font-size:11.5px; color:var(--iux-text-dim); }
484
+ #__iux_settings_pop .kbd-row { display:flex; justify-content:space-between; font-size:11px; color:var(--iux-text-faint); padding:3px 0; }
485
+ #__iux_settings_pop .ver { margin-top:8px; font-size:10px; color:var(--iux-text-faint); text-align:center; }
486
+
487
  #__insightux_update {
488
  display:none; cursor:pointer; flex:0 0 auto; white-space:nowrap;
489
+ padding:6px 13px; border-radius:999px; font-weight:600; font-size:11px;
490
+ background:var(--iux-accent-grad); color:#fff; align-items:center; gap:6px;
491
  }
492
+ #__insightux_update:hover { filter:brightness(1.12); transform:translateY(-1px); }
493
  `;
494
  document.head.appendChild(style);
495
 
496
+ const loadbar = document.createElement('div');
497
+ loadbar.id = '__iux_loadbar';
498
+ document.documentElement.appendChild(loadbar);
499
+ requestAnimationFrame(function(){
500
+ loadbar.style.opacity = '1'; loadbar.style.width = '70%';
501
+ setTimeout(function(){ loadbar.style.width = '100%'; }, 160);
502
+ setTimeout(function(){ loadbar.style.opacity = '0'; }, 550);
503
+ });
504
+
505
  const toolbar = document.createElement('div');
506
  toolbar.id = '__insightux_toolbar';
507
+ toolbar.className = 'iux-glass';
508
  toolbar.innerHTML = `
509
+ <button type="button" class="iux-btn iux-navbtn" id="__iux_back" title="Back">${iuxIcon('arrow-left',15)}</button>
510
+ <button type="button" class="iux-btn iux-navbtn" id="__iux_fwd" title="Forward">${iuxIcon('arrow-right',15)}</button>
511
+ <button type="button" class="iux-btn iux-navbtn" id="__iux_reload" title="Reload">${iuxIcon('refresh',15)}</button>
512
+ <button type="button" class="iux-btn iux-navbtn" id="__iux_home" title="Home">${iuxIcon('home',15)}</button>
513
+ <div id="__iux_addr_wrap">
514
+ <span class="lead-icon" id="__iux_security_icon"></span>
515
+ <form id="__iux_addr_form"><input id="__iux_addr" type="text" autocomplete="off"
516
+ placeholder="Search or enter address"></form>
517
+ </div>
518
+ <div id="__insightux_indicators">
519
+ <span class="iux-pill" id="__iux_pill_eye"><span class="dot"></span>Eye Tracking</span>
520
+ <span class="iux-pill" id="__iux_pill_mouse"><span class="dot"></span>Mouse Tracking</span>
521
+ <span class="iux-pill" id="__iux_pill_time" style="display:none;">${iuxIcon('clock',11)}<span class="txt">00:00</span></span>
522
+ </div>
523
  <span id="__insightux_update" title="Click to open the download page"></span>
524
+ <span id="__insightux_status" class="iux-pill"><span class="txt">Press S to start &middot; H heatmap &middot; M panel &middot; X quit</span></span>
525
+ <button type="button" class="iux-btn" id="__iux_theme_toggle" title="Toggle theme" style="width:32px;height:32px;"></button>
526
  `;
527
  document.documentElement.appendChild(toolbar);
528
 
529
  const sidebar = document.createElement('div');
530
  sidebar.id = '__insightux_sidebar';
531
+ sidebar.className = 'iux-glass';
532
  sidebar.innerHTML = `
533
+ <button type="button" class="iux-btn side-btn" id="__iux_calibrate">${iuxIcon('eye',18)}<span class="lbl">Calibrate</span><span class="tip">Calibrate</span></button>
534
+ <button type="button" class="iux-btn side-btn" id="__iux_validate">${iuxIcon('target',18)}<span class="lbl">Validate</span><span class="tip">Validate</span></button>
535
+ <button type="button" class="iux-btn side-btn" id="__iux_session">${iuxIcon('play',18)}<span class="lbl">Start<br>Session</span><span class="tip">Start Session</span></button>
536
+ <div class="spacer"></div>
537
+ <div style="position:relative;">
538
+ <button type="button" class="iux-btn side-btn" id="__iux_settings_btn">${iuxIcon('settings',18)}<span class="lbl">Settings</span><span class="tip">Settings</span></button>
539
+ <div id="__iux_settings_pop" class="iux-glass iux-fade-in">
540
+ <h4>Shortcuts</h4>
541
+ <div class="kbd-row"><span>Start / Stop tracking</span><b>S / E</b></div>
542
+ <div class="kbd-row"><span>Toggle heatmap</span><b>H</b></div>
543
+ <div class="kbd-row"><span>Mouse panel</span><b>M</b></div>
544
+ <div class="kbd-row"><span>Quit</span><b>X</b></div>
545
+ <div class="ver">InsightUX v__INSIGHTUX_VERSION__</div>
546
+ </div>
547
+ </div>
548
+ <button type="button" class="iux-btn" id="__iux_collapse_btn" title="Collapse sidebar">${iuxIcon('chevron-left',14)}</button>
549
+ <div class="hints">S/E &middot; H &middot; M &middot; X</div>
550
  `;
551
  document.documentElement.appendChild(sidebar);
552
 
 
555
  if (!document.documentElement.contains(sidebar)) document.documentElement.appendChild(sidebar);
556
  }, 1000);
557
 
558
+ // -- theme toggle --
559
+ const themeBtn = document.getElementById('__iux_theme_toggle');
560
+ function paintThemeBtn(){
561
+ themeBtn.innerHTML = iuxIcon(window.insightuxGetTheme() === 'light' ? 'moon' : 'sun', 15);
562
+ }
563
+ paintThemeBtn();
564
+ themeBtn.addEventListener('click', function(){ window.insightuxToggleTheme(); paintThemeBtn(); });
565
+
566
+ // -- sidebar collapse (persisted like theme) --
567
+ const collapseBtn = document.getElementById('__iux_collapse_btn');
568
+ function applyCollapsed(collapsed){
569
+ sidebar.classList.toggle('collapsed', collapsed);
570
+ collapseBtn.innerHTML = iuxIcon(collapsed ? 'chevron-right' : 'chevron-left', 14);
571
+ }
572
+ applyCollapsed(window.insightuxGetPrefs().sidebarCollapsed === true);
573
+ collapseBtn.addEventListener('click', function(){
574
+ const collapsed = !sidebar.classList.contains('collapsed');
575
+ applyCollapsed(collapsed);
576
+ window.insightuxSetPref('sidebarCollapsed', collapsed);
577
+ });
578
+
579
+ // -- settings popover --
580
+ const settingsBtn = document.getElementById('__iux_settings_btn');
581
+ const settingsPop = document.getElementById('__iux_settings_pop');
582
+ settingsBtn.addEventListener('click', function(e){
583
+ e.stopPropagation();
584
+ settingsPop.classList.toggle('open');
585
+ });
586
+ document.addEventListener('click', function(){ settingsPop.classList.remove('open'); });
587
+
588
+ // -- security icon (padlock for https, globe otherwise) --
589
+ document.getElementById('__iux_security_icon').innerHTML =
590
+ iuxIcon(location.protocol === 'https:' ? 'lock' : 'globe', 14);
591
+
592
+ // -- status text / pills, driven by the same Python calls as before --
593
+ const statusEl = () => document.querySelector('#__insightux_status .txt');
594
  window.insightuxSetStatus = function(text){
595
  const el = statusEl();
596
  if (el) el.textContent = text;
597
  };
598
+
599
+ let sessionStartedAt = null, timerInterval = null;
600
+ function tickTimer(){
601
+ const pill = document.getElementById('__iux_pill_time');
602
+ if (!sessionStartedAt) { pill.style.display = 'none'; return; }
603
+ const diff = Math.floor((Date.now() - sessionStartedAt) / 1000);
604
+ const m = String(Math.floor(diff / 60)).padStart(2, '0');
605
+ const s = String(diff % 60).padStart(2, '0');
606
+ pill.querySelector('.txt').textContent = m + ':' + s;
607
+ pill.style.display = 'inline-flex';
608
+ }
609
  window.insightuxSetSessionUI = function(isTracking){
610
  const btn = document.getElementById('__iux_session');
611
+ if (btn) {
612
+ btn.innerHTML = isTracking
613
+ ? iuxIcon('stop',18) + '<span class="lbl">Stop<br>Session</span><span class="tip">Stop Session</span>'
614
+ : iuxIcon('play',18) + '<span class="lbl">Start<br>Session</span><span class="tip">Start Session</span>';
615
+ btn.classList.toggle('active', isTracking);
616
+ }
617
+ document.getElementById('__iux_pill_eye').classList.toggle('on', isTracking);
618
+ document.getElementById('__iux_pill_mouse').classList.toggle('on', isTracking);
619
+ if (isTracking) {
620
+ sessionStartedAt = Date.now();
621
+ if (timerInterval) clearInterval(timerInterval);
622
+ timerInterval = setInterval(tickTimer, 1000);
623
+ tickTimer();
624
+ } else {
625
+ sessionStartedAt = null;
626
+ if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
627
+ tickTimer();
628
+ }
629
  };
630
  window.insightuxShowUpdate = function(version, url){
631
  const el = document.getElementById('__insightux_update');
632
  if (!el) return;
633
+ el.innerHTML = iuxIcon('download', 12) + '<span>Update available (v' + version + ')</span>';
634
+ el.style.display = 'inline-flex';
635
  el.onclick = function(){
636
  if (window.pywebview && window.pywebview.api && window.pywebview.api.open_update_page) {
637
  window.pywebview.api.open_update_page(url);
 
709
  })();
710
  """
711
  CHROME_JS = CHROME_JS.replace("__INSIGHTUX_LANDING_URL__", json.dumps(LANDING_URL))
712
+ CHROME_JS = CHROME_JS.replace("__ICONS_JS__", theme.ICONS_JS)
713
+ CHROME_JS = CHROME_JS.replace("__THEME_JS__", theme.THEME_TOGGLE_JS)
714
+ CHROME_JS = CHROME_JS.replace("__THEME_CSS__", theme.THEME_CSS)
715
+ CHROME_JS = CHROME_JS.replace("__INSIGHTUX_VERSION__", VERSION)
716
 
717
  # JS: tracking overlay (gaze dot + AOI highlighting) — injected only once
718
  # tracking actually starts. Ported directly from run_session.py's JS_SETUP.
 
743
  if (!document.body.contains(cv)) document.body.appendChild(cv);
744
  }, 1000);
745
 
746
+ function roundRectPath(c, x, y, w, h, r){
747
+ c.beginPath();
748
+ c.moveTo(x + r, y);
749
+ c.arcTo(x + w, y, x + w, y + h, r);
750
+ c.arcTo(x + w, y + h, x, y + h, r);
751
+ c.arcTo(x, y + h, x, y, r);
752
+ c.arcTo(x, y, x + w, y, r);
753
+ c.closePath();
754
+ }
755
+
756
  const MIN_W=40, MIN_H=24, MAX_AOIS=90, MAX_SCAN=2500;
757
  const PAD_PX = 90;
758
  const TALL_WRAPPER_H = 220;
 
913
  ctx.clearRect(0,0,cv.width,cv.height);
914
 
915
  if (activeBox){
916
+ ctx.save();
917
+ ctx.fillStyle = 'rgba(99,102,241,0.14)';
918
+ ctx.strokeStyle = '#8B5CF6'; ctx.lineWidth = 2.5;
919
+ ctx.shadowColor = 'rgba(139,92,246,0.55)'; ctx.shadowBlur = 14;
920
+ roundRectPath(ctx, activeBox.x, activeBox.y, activeBox.w, activeBox.h, 8);
921
+ ctx.fill();
922
+ ctx.shadowBlur = 0;
923
+ ctx.stroke();
924
+ ctx.restore();
925
+
926
  const label = activeBox.label;
927
+ ctx.font = '600 12px -apple-system, "Segoe UI", Arial';
928
+ const tw = ctx.measureText(label).width + 20;
929
+ const ly = Math.max(0, activeBox.y - 26);
930
+ ctx.save();
931
+ const grad = ctx.createLinearGradient(activeBox.x, ly, activeBox.x + tw, ly);
932
+ grad.addColorStop(0, '#6366F1'); grad.addColorStop(1, '#C084FC');
933
+ ctx.fillStyle = grad;
934
+ roundRectPath(ctx, activeBox.x, ly, tw, 22, 11);
935
+ ctx.fill();
936
  ctx.fillStyle = '#FFFFFF';
937
+ ctx.fillText(label, activeBox.x + 10, ly + 15);
938
+ ctx.restore();
939
  }
940
 
941
+ // soft pulsing ring around the dot, on a slow independent cycle
942
+ const pulse = (Math.sin(now / 420) + 1) / 2;
943
  ctx.beginPath();
944
+ ctx.arc(dot.x, dot.y, 15 + pulse * 5, 0, 2*Math.PI);
945
+ ctx.strokeStyle = 'rgba(139,92,246,' + (0.35 - pulse * 0.2) + ')';
946
+ ctx.lineWidth = 1.5;
947
+ ctx.stroke();
948
+
949
+ ctx.save();
950
+ ctx.shadowColor = 'rgba(99,102,241,0.65)'; ctx.shadowBlur = 12;
951
+ ctx.beginPath();
952
+ ctx.arc(dot.x, dot.y, 12, 0, 2*Math.PI);
953
+ ctx.fillStyle = 'rgba(255,255,255,0.92)';
954
  ctx.fill();
955
+ ctx.restore();
956
+
957
  ctx.beginPath();
958
+ ctx.arc(dot.x, dot.y, 8, 0, 2*Math.PI);
959
+ const dotGrad = ctx.createRadialGradient(dot.x - 2, dot.y - 2, 1, dot.x, dot.y, 8);
960
+ dotGrad.addColorStop(0, '#C084FC'); dotGrad.addColorStop(1, '#6366F1');
961
+ ctx.fillStyle = dotGrad;
962
  ctx.fill();
963
+ ctx.lineWidth = 1.5;
964
+ ctx.strokeStyle = 'rgba(18,19,26,0.55)';
965
  ctx.stroke();
966
 
967
  requestAnimationFrame(renderLoop);
 
1212
  }
1213
 
1214
  // ---- heatmap overlay canvas (ported 1:1 from the extension) ----
1215
+ let heatOpacity = 0.85, heatIntensity = 0.05;
1216
  window.insightuxMouseToggleHeatmap = function(){
1217
  if (canvas) { document.body.removeChild(canvas); canvas = null; return; }
1218
  canvas = document.createElement('canvas');
 
1220
  canvas.style.top = '0'; canvas.style.left = '0';
1221
  canvas.style.zIndex = '2147483645';
1222
  canvas.style.pointerEvents = 'none';
1223
+ canvas.style.opacity = heatOpacity;
1224
+ canvas.style.transition = 'opacity .15s ease';
1225
  canvas.width = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, document.documentElement.offsetWidth);
1226
  canvas.height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, document.documentElement.offsetHeight);
1227
  document.body.appendChild(canvas);
1228
  drawHeatmapHighQuality(allHeatmap, allTrail, allClicks);
1229
  };
1230
+ window.insightuxMouseSetHeatStyle = function(opacity, intensity){
1231
+ if (opacity != null) { heatOpacity = opacity; if (canvas) canvas.style.opacity = heatOpacity; }
1232
+ if (intensity != null) { heatIntensity = intensity; if (canvas) drawHeatmapHighQuality(allHeatmap, allTrail, allClicks); }
1233
+ };
1234
 
1235
  function drawClick(x, y){
1236
  if (!canvas) return;
 
1263
  brushCanvas.width = radius * 2; brushCanvas.height = radius * 2;
1264
  const brushCtx = brushCanvas.getContext('2d');
1265
  const g = brushCtx.createRadialGradient(radius, radius, 0, radius, radius, radius);
1266
+ g.addColorStop(0, 'rgba(0, 0, 0, ' + heatIntensity + ')'); g.addColorStop(1, 'rgba(0, 0, 0, 0)');
1267
  brushCtx.fillStyle = g; brushCtx.fillRect(0, 0, radius * 2, radius * 2);
1268
  allHeatPoints.forEach(function(point){ ctx.drawImage(brushCanvas, point.x - radius, point.y - radius); });
1269
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
 
1304
  });
1305
 
1306
  // ---- insights panel: in-page port of the extension's popup.html/popup.js ----
1307
+ __ICONS_JS__
1308
  const style = document.createElement('style');
1309
  style.textContent = `
1310
+ __THEME_CSS__
1311
  #__insightux_mouse_panel {
1312
+ position: fixed; top: 66px; right: 16px; width: 268px; z-index: 2147483647;
1313
+ border-radius: var(--iux-radius); box-shadow: var(--iux-shadow);
1314
+ font: 12px var(--iux-font); color: var(--iux-text); padding: 14px;
 
1315
  pointer-events: auto; display: none;
1316
+ transform: translateY(-8px); opacity: 0; transition: transform .2s var(--iux-ease), opacity .2s var(--iux-ease);
1317
  }
1318
  #__insightux_mouse_panel.open { display: block; }
1319
+ #__insightux_mouse_panel.show { transform: translateY(0); opacity: 1; }
1320
+ #__insightux_mouse_panel h3 { margin: 0 0 10px 0; font-size: 12.5px; color: var(--iux-text); font-weight: 600;
1321
+ display:flex; align-items:center; gap:6px; }
1322
+ #__insightux_mouse_panel .muted { color: var(--iux-text-faint); font-size: 11px; }
1323
+ #__insightux_mouse_panel .row { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
1324
+ #__insightux_mouse_panel .row .iux-btn {
1325
+ flex: 1 1 auto; padding: 7px 6px; font-size: 10.5px; gap: 4px;
1326
  }
1327
+ #__insightux_mouse_panel .row .iux-btn.active { background: var(--iux-accent-grad); border-color: transparent; color: #fff; }
1328
+ #__insightux_mouse_panel .timer { font-weight: 600; margin-bottom: 10px; display:flex; align-items:center; gap:6px; color: var(--iux-text-dim); }
1329
  #__insightux_mouse_panel .hero {
1330
+ background: linear-gradient(135deg, color-mix(in srgb, var(--iux-primary) 16%, transparent), color-mix(in srgb, var(--iux-indigo) 10%, transparent));
1331
+ border: 1px solid var(--iux-border); border-radius: var(--iux-radius-sm);
1332
+ padding: 10px; text-align: center; margin-bottom: 10px;
1333
  }
1334
+ #__insightux_mouse_panel .hero .lbl { font-size: 9px; text-transform: uppercase; color: var(--iux-text-faint); letter-spacing: 0.06em; }
1335
+ #__insightux_mouse_panel .hero .el { font-weight: 700; margin: 4px 0; color: var(--iux-primary-light); }
1336
  #__insightux_mouse_panel .interest-item, #__insightux_mouse_panel .log-item {
1337
+ background: var(--iux-surface-hi); border-radius: 8px; padding: 6px 8px; margin-bottom: 5px; font-size: 11px;
1338
  }
1339
  #__insightux_mouse_panel .list { max-height: 160px; overflow-y: auto; }
1340
+ #__insightux_mouse_panel .sliders { display: none; margin-bottom: 10px; }
1341
+ #__insightux_mouse_panel .sliders.open { display: block; }
1342
+ #__insightux_mouse_panel .slider-row { display:flex; align-items:center; gap:8px; margin-bottom:6px; font-size: 10.5px; color: var(--iux-text-dim); }
1343
+ #__insightux_mouse_panel .slider-row span { flex: 0 0 52px; }
1344
+ #__insightux_mouse_panel input[type=range] { flex: 1 1 auto; accent-color: var(--iux-primary-light); }
1345
  `;
1346
  document.head.appendChild(style);
1347
 
1348
  const panel = document.createElement('div');
1349
  panel.id = '__insightux_mouse_panel';
1350
+ panel.className = 'iux-glass';
1351
  panel.innerHTML = `
1352
+ <h3>${iuxIcon('cursor', 14)} Mouse Tracker <span class="muted" id="__mt_status" style="margin-left:auto;">idle</span></h3>
1353
+ <div class="timer">${iuxIcon('clock', 13)} <span id="__mt_timer">00:00</span></div>
1354
  <div class="row">
1355
+ <button class="iux-btn" id="__mt_clear" title="Clear collected data">${iuxIcon('trash', 13)} Clear</button>
1356
+ <button class="iux-btn" id="__mt_heatmap" title="Toggle heatmap overlay (H)">${iuxIcon('layers', 13)} Heatmap</button>
1357
+ <button class="iux-btn" id="__mt_logs" title="View click log">${iuxIcon('type', 13)} Logs</button>
1358
+ </div>
1359
+ <div class="sliders" id="__mt_sliders">
1360
+ <div class="slider-row"><span>Opacity</span><input type="range" id="__mt_opacity" min="20" max="100" value="85"></div>
1361
+ <div class="slider-row"><span>Intensity</span><input type="range" id="__mt_intensity" min="2" max="12" value="5"></div>
1362
  </div>
1363
  <div id="__mt_hero" class="hero" style="display:none;">
1364
  <div class="lbl">Most Viewed Element</div>
 
1369
  <div id="__mt_logs_list" class="list" style="display:none;"></div>
1370
  `;
1371
  document.documentElement.appendChild(panel);
1372
+ requestAnimationFrame(function(){ panel.classList.add('show'); });
1373
 
1374
  document.getElementById('__mt_clear').addEventListener('click', function(){
1375
  trailBuffer = []; heatmapBuffer = []; clickBuffer = []; dwellBuffer = [];
 
1378
  if (canvas) { document.body.removeChild(canvas); canvas = null; }
1379
  updatePanel();
1380
  });
1381
+ const heatmapBtn = document.getElementById('__mt_heatmap');
1382
+ const slidersEl = document.getElementById('__mt_sliders');
1383
+ heatmapBtn.addEventListener('click', function(){
1384
+ window.insightuxMouseToggleHeatmap();
1385
+ heatmapBtn.classList.toggle('active', !!canvas);
1386
+ slidersEl.classList.toggle('open', !!canvas);
1387
+ });
1388
+ document.getElementById('__mt_opacity').addEventListener('input', function(e){
1389
+ window.insightuxMouseSetHeatStyle(e.target.value / 100, null);
1390
+ });
1391
+ document.getElementById('__mt_intensity').addEventListener('input', function(e){
1392
+ window.insightuxMouseSetHeatStyle(null, e.target.value / 100);
1393
+ });
1394
  document.getElementById('__mt_logs').addEventListener('click', function(){
1395
  logsOpen = !logsOpen;
1396
+ document.getElementById('__mt_logs').classList.toggle('active', logsOpen);
1397
  document.getElementById('__mt_logs_list').style.display = logsOpen ? 'block' : 'none';
1398
  document.getElementById('__mt_interests').style.display = logsOpen ? 'none' : 'block';
1399
  updatePanel();
 
1469
  updatePanel();
1470
  })();
1471
  """
1472
+ MOUSE_JS = MOUSE_JS.replace("__ICONS_JS__", theme.ICONS_JS)
1473
+ MOUSE_JS = MOUSE_JS.replace("__THEME_CSS__", theme.THEME_CSS)
1474
 
1475
 
1476
  # =============================================================================
theme.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ theme.py
3
+ Single source of truth for InsightUX's visual design system: CSS custom
4
+ properties (dark + light), a handful of shared utility classes, an inline
5
+ icon set, and the theme-toggle/persistence script.
6
+
7
+ Every InsightUX UI surface is a separate HTML document or an independently
8
+ injected JS bundle (landing page, toolbar/sidebar overlay, session report,
9
+ mouse-tracker panel) — none of them share a stylesheet at runtime. This
10
+ module exists so all four still look like one product: each template
11
+ splices THEME_CSS + THEME_TOGGLE_JS into its own <style>/<script>, instead
12
+ of every surface hand-rolling its own palette.
13
+
14
+ No heavy imports (no cv2/mediapipe/pyautogui) so analysis.py can import
15
+ this standalone, the same way it already runs outside the pywebview app.
16
+ """
17
+
18
+ import json
19
+
20
+ # =============================================================================
21
+ # ICONS — one inline SVG path set, reused from both Python (string-built
22
+ # templates) and JS (client-side rendering in the report). Kept intentionally
23
+ # small: only the glyphs actually used across the four surfaces.
24
+ # =============================================================================
25
+
26
+ ICON_PATHS = {
27
+ "search": '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
28
+ "x": '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
29
+ "sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>',
30
+ "moon": '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
31
+ "eye": '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>',
32
+ "cursor": '<path d="M12.586 12.586 19 19"/><path d="M3.688 3.037a.497.497 0 0 0-.651.651l6.5 15.999a.501.501 0 0 0 .947-.062l1.569-6.083a2 2 0 0 1 1.448-1.479l6.124-1.579a.5.5 0 0 0 .063-.947z"/>',
33
+ "target": '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
34
+ "play": '<polygon points="6 3 20 12 6 21 6 3"/>',
35
+ "stop": '<rect width="16" height="16" x="4" y="4" rx="3"/>',
36
+ "home": '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/>',
37
+ "arrow-left": '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
38
+ "arrow-right": '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
39
+ "refresh": '<path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/>',
40
+ "lock": '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
41
+ "globe": '<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10Z"/>',
42
+ "image": '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
43
+ "type": '<path d="M17 6.1H3"/><path d="M21 12.1H3"/><path d="M15.1 18H3"/>',
44
+ "video": '<path d="m16 13 5.2 3.5a.5.5 0 0 0 .8-.4V7.9a.5.5 0 0 0-.8-.4L16 10.5"/><rect x="2" y="6" width="14" height="12" rx="2"/>',
45
+ "click": '<path d="m9 9 5 12 1.8-5.2L21 14Z"/><path d="M7.2 2.2 8 5.1"/><path d="m5.1 8-2.9-.8"/><path d="M14 4.1 12 6"/><path d="m6 12-1.9 2"/>',
46
+ "download": '<path d="M12 15V3"/><path d="M3 15v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4"/><path d="m7 10 5 5 5-5"/>',
47
+ "maximize": '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>',
48
+ "sliders": '<line x1="4" x2="4" y1="21" y2="14"/><line x1="4" x2="4" y1="10" y2="3"/><line x1="12" x2="12" y1="21" y2="12"/><line x1="12" x2="12" y1="8" y2="3"/><line x1="20" x2="20" y1="21" y2="16"/><line x1="20" x2="20" y1="12" y2="3"/><line x1="1" x2="7" y1="14" y2="14"/><line x1="9" x2="15" y1="8" y2="8"/><line x1="17" x2="23" y1="16" y2="16"/>',
49
+ "clock": '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
50
+ "chevron-down": '<path d="m6 9 6 6 6-6"/>',
51
+ "chevron-left": '<path d="m15 18-6-6 6-6"/>',
52
+ "chevron-right": '<path d="m9 18 6-6-6-6"/>',
53
+ "settings": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/>',
54
+ "layers": '<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 12.32-8.58 3.9a2 2 0 0 1-1.66 0L2 12.32"/><path d="m22 17.32-8.58 3.9a2 2 0 0 1-1.66 0L2 17.32"/>',
55
+ "trash": '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
56
+ "info": '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
57
+ "check": '<path d="M20 6 9 17l-5-5"/>',
58
+ "camera": '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
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
+ }
61
+
62
+
63
+ def icon(name, size=18, css_class=""):
64
+ """Python-side inline <svg> for a template string."""
65
+ body = ICON_PATHS.get(name, "")
66
+ cls = ("iux-icon " + css_class).strip()
67
+ return (
68
+ f'<svg class="{cls}" width="{size}" height="{size}" viewBox="0 0 24 24" '
69
+ f'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
70
+ f'stroke-linejoin="round">{body}</svg>'
71
+ )
72
+
73
+
74
+ ICONS_JS = (
75
+ "const IUX_ICONS = " + json.dumps(ICON_PATHS) + ";\n"
76
+ "function iuxIcon(name, size){\n"
77
+ " size = size || 18;\n"
78
+ " return '<svg class=\"iux-icon\" width=\"' + size + '\" height=\"' + size + '\" "
79
+ "viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" "
80
+ "stroke-linecap=\"round\" stroke-linejoin=\"round\">' + (IUX_ICONS[name] || '') + '</svg>';\n"
81
+ "}\n"
82
+ )
83
+
84
+
85
+ # =============================================================================
86
+ # CSS — shared custom properties + a small utility layer. Dark is the
87
+ # default (:root); :root[data-theme="light"] overrides. Deep charcoal, not
88
+ # pitch black, for dark; warm off-white, not pure white, for light.
89
+ # =============================================================================
90
+
91
+ THEME_CSS = r"""
92
+ :root {
93
+ --iux-bg: #12131A;
94
+ --iux-bg-alt: #171923;
95
+ --iux-surface: #1E2030;
96
+ --iux-surface-hi: #262940;
97
+ --iux-border: rgba(148, 138, 179, 0.16);
98
+ --iux-text: #F1EEFA;
99
+ --iux-text-dim: #9B95B3;
100
+ --iux-text-faint: #6E6885;
101
+
102
+ --iux-primary: #7C3AED;
103
+ --iux-primary-light: #8B5CF6;
104
+ --iux-indigo: #6366F1;
105
+ --iux-accent-grad: linear-gradient(135deg, #6366F1, #8B5CF6 55%, #C084FC);
106
+ --iux-cyan: #67E8F9;
107
+ --iux-lavender: #C4B5FD;
108
+
109
+ --iux-success: #34D399;
110
+ --iux-warning: #FBBF24;
111
+ --iux-danger: #FB7185;
112
+ --iux-info: #38BDF8;
113
+
114
+ --iux-radius-sm: 8px;
115
+ --iux-radius: 14px;
116
+ --iux-radius-lg: 20px;
117
+ --iux-shadow-sm: 0 2px 10px rgba(0,0,0,0.28);
118
+ --iux-shadow: 0 10px 34px rgba(10,6,24,0.45);
119
+ --iux-shadow-glow: 0 0 0 1px rgba(139,92,246,0.35), 0 8px 28px rgba(124,58,237,0.28);
120
+ --iux-blur: blur(18px);
121
+ --iux-ease: cubic-bezier(0.22, 1, 0.36, 1);
122
+ --iux-font: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Manrope', Arial, sans-serif;
123
+ }
124
+
125
+ :root[data-theme="light"] {
126
+ --iux-bg: #F7F5FB;
127
+ --iux-bg-alt: #F1EEFA;
128
+ --iux-surface: #FFFFFF;
129
+ --iux-surface-hi: #F3F0FA;
130
+ --iux-border: rgba(99, 79, 150, 0.14);
131
+ --iux-text: #211D33;
132
+ --iux-text-dim: #635C7D;
133
+ --iux-text-faint: #918AA8;
134
+
135
+ --iux-shadow-sm: 0 2px 10px rgba(76,60,120,0.08);
136
+ --iux-shadow: 0 14px 34px rgba(76,60,120,0.14);
137
+ --iux-shadow-glow: 0 0 0 1px rgba(124,58,237,0.18), 0 8px 24px rgba(124,58,237,0.16);
138
+ }
139
+
140
+ .iux-icon { display: inline-block; vertical-align: middle; flex-shrink: 0; }
141
+
142
+ .iux-btn {
143
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
144
+ border-radius: var(--iux-radius-sm); border: 1px solid var(--iux-border);
145
+ background: var(--iux-surface); color: var(--iux-text); cursor: pointer;
146
+ font: 500 12.5px var(--iux-font);
147
+ transition: transform 0.15s var(--iux-ease), background 0.15s var(--iux-ease), box-shadow 0.15s var(--iux-ease);
148
+ }
149
+ .iux-btn:hover { background: var(--iux-surface-hi); transform: translateY(-1px); }
150
+ .iux-btn:active { transform: translateY(0) scale(0.97); }
151
+ .iux-btn.primary { background: var(--iux-accent-grad); border-color: transparent; color: #fff; }
152
+ .iux-btn.primary:hover { box-shadow: var(--iux-shadow-glow); }
153
+
154
+ .iux-card {
155
+ background: var(--iux-surface); border: 1px solid var(--iux-border);
156
+ border-radius: var(--iux-radius); box-shadow: var(--iux-shadow-sm);
157
+ transition: transform 0.18s var(--iux-ease), box-shadow 0.18s var(--iux-ease);
158
+ }
159
+ .iux-card.hoverable:hover { transform: translateY(-2px); box-shadow: var(--iux-shadow); }
160
+
161
+ .iux-glass {
162
+ background: color-mix(in srgb, var(--iux-surface) 82%, transparent);
163
+ backdrop-filter: var(--iux-blur); -webkit-backdrop-filter: var(--iux-blur);
164
+ border: 1px solid var(--iux-border);
165
+ }
166
+
167
+ .iux-pill {
168
+ display: inline-flex; align-items: center; gap: 6px;
169
+ padding: 5px 12px; border-radius: 999px; font: 600 11px var(--iux-font);
170
+ border: 1px solid var(--iux-border); background: var(--iux-surface-hi); color: var(--iux-text-dim);
171
+ white-space: nowrap;
172
+ }
173
+ .iux-pill .dot {
174
+ width: 7px; height: 7px; border-radius: 50%; background: var(--iux-text-faint); flex-shrink: 0;
175
+ }
176
+ .iux-pill.on { color: var(--iux-success); border-color: rgba(52,211,153,0.35); background: rgba(52,211,153,0.1); }
177
+ .iux-pill.on .dot { background: var(--iux-success); animation: iux-pulse 1.6s ease-in-out infinite; }
178
+
179
+ @keyframes iux-pulse {
180
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(52,211,153,0.55); }
181
+ 70% { box-shadow: 0 0 0 6px rgba(52,211,153,0); }
182
+ }
183
+
184
+ .iux-toast {
185
+ display: flex; align-items: center; gap: 8px; padding: 10px 14px;
186
+ border-radius: var(--iux-radius-sm); background: var(--iux-surface); color: var(--iux-text);
187
+ border: 1px solid var(--iux-border); box-shadow: var(--iux-shadow);
188
+ font: 500 12.5px var(--iux-font);
189
+ animation: iux-toast-in 0.25s var(--iux-ease);
190
+ }
191
+ @keyframes iux-toast-in {
192
+ from { opacity: 0; transform: translateY(-6px) scale(0.98); }
193
+ to { opacity: 1; transform: translateY(0) scale(1); }
194
+ }
195
+
196
+ .iux-fade-in { animation: iux-fade 0.35s var(--iux-ease); }
197
+ @keyframes iux-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
198
+
199
+ ::-webkit-scrollbar { width: 9px; height: 9px; }
200
+ ::-webkit-scrollbar-track { background: transparent; }
201
+ ::-webkit-scrollbar-thumb { background: var(--iux-border); border-radius: 8px; }
202
+ ::-webkit-scrollbar-thumb:hover { background: var(--iux-text-faint); }
203
+ """
204
+
205
+
206
+ # =============================================================================
207
+ # THEME PERSISTENCE — pure frontend, no Api/IPC calls.
208
+ #
209
+ # CHROME_JS and MOUSE_JS are re-injected into whatever real website is
210
+ # currently loaded (google.com, wikipedia.org, ...), each a different
211
+ # origin, so localStorage (per-origin) can't carry a preference between
212
+ # them. window.name is the standard trick here: it survives navigation
213
+ # across origins within the same tab/window and is only cleared when the
214
+ # window itself closes. localStorage is kept as a secondary mirror so the
215
+ # landing page and the generated report (both file:// — same origin) stay
216
+ # in sync with each other even in a fresh window.
217
+ # =============================================================================
218
+
219
+ THEME_TOGGLE_JS = r"""
220
+ (function(){
221
+ function readPrefs(){
222
+ try {
223
+ if (window.name && window.name.indexOf('__iuxprefs__:') === 0) {
224
+ return JSON.parse(window.name.slice(13));
225
+ }
226
+ } catch (e) {}
227
+ try {
228
+ const raw = localStorage.getItem('__iux_prefs__');
229
+ if (raw) return JSON.parse(raw);
230
+ } catch (e) {}
231
+ return {};
232
+ }
233
+ function writePrefs(prefs){
234
+ try { window.name = '__iuxprefs__:' + JSON.stringify(prefs); } catch (e) {}
235
+ try { localStorage.setItem('__iux_prefs__', JSON.stringify(prefs)); } catch (e) {}
236
+ }
237
+ const prefs = readPrefs();
238
+ const theme = prefs.theme === 'light' ? 'light' : 'dark';
239
+ document.documentElement.setAttribute('data-theme', theme);
240
+
241
+ window.insightuxGetPrefs = readPrefs;
242
+ window.insightuxSetPref = function(key, value){
243
+ const p = readPrefs();
244
+ p[key] = value;
245
+ writePrefs(p);
246
+ };
247
+ window.insightuxGetTheme = function(){ return readPrefs().theme === 'light' ? 'light' : 'dark'; };
248
+ window.insightuxToggleTheme = function(){
249
+ const next = window.insightuxGetTheme() === 'light' ? 'dark' : 'light';
250
+ document.documentElement.setAttribute('data-theme', next);
251
+ window.insightuxSetPref('theme', next);
252
+ return next;
253
+ };
254
+ })();
255
+ """