Redesign frontend UI with a shared purple/indigo theme system
Browse filesAdds 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>
- .gitignore +1 -0
- analysis.py +371 -120
- browser_session.py +443 -122
- 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} —
|
| 128 |
m = re.match(r"^p \((.*)\)$", raw)
|
| 129 |
if m:
|
| 130 |
-
return f"Text —
|
| 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 |
-
|
| 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: -
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
}
|
| 288 |
-
|
| 289 |
-
.
|
| 290 |
-
.
|
| 291 |
-
|
| 292 |
-
background: var(--
|
| 293 |
-
padding: 20px;
|
| 294 |
}
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
.
|
| 303 |
-
canvas { display: block; }
|
| 304 |
.full { grid-column: 1 / -1; }
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
.
|
| 311 |
-
.
|
| 312 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
.legend-scale { display: flex; align-items: center; gap: 8px; margin: 10px 0; }
|
| 314 |
-
.legend-scale .bar { flex: 1; height:
|
| 315 |
background: linear-gradient(90deg, #0000ff, #00e5ff, #00ff5a, #e8ff00, #ff2d00); }
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 322 |
-
|
| 323 |
-
|
| 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 |
-
<
|
| 332 |
-
<div class="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
|
|
|
| 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
|
| 410 |
-
<span style="color:var(--
|
| 411 |
<b>${c.element || ''}</b><br>
|
| 412 |
-
<span style="color:var(--text);font-size:12px;">"${(c.text || '').replace(/</g,'<')}"</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 |
-
|
| 424 |
-
|
| 425 |
-
RANKING.
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
</
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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':''}>
|
| 478 |
<span>Scroll segment ${i+1} of ${SEGMENTS.length} · ~${seg.duration}s of attention here</span>
|
| 479 |
-
<button id="nextBtn" ${i===SEGMENTS.length-1?'disabled':''}>Later
|
| 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
|
| 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,
|
| 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 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 557 |
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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__ · __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 & 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,'<')}"</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} · ~${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;
|
| 125 |
-
align-items: center; justify-content: center;
|
| 126 |
-
background:
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
.logo {
|
| 130 |
-
font-size:
|
| 131 |
-
background:
|
| 132 |
-
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
}
|
| 135 |
-
.tagline { color: #8a8098; font-size: 14px; margin-bottom: 36px; }
|
| 136 |
-
form { width: 560px; max-width: 88vw; }
|
| 137 |
input {
|
| 138 |
-
width: 100%; padding:
|
| 139 |
-
border: 1.5px solid
|
| 140 |
-
outline: none; transition: border-color 0.15s;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
}
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
.hint {
|
| 145 |
-
|
| 146 |
-
line-height: 1.6;
|
| 147 |
}
|
| 148 |
-
.hint b { color: #9B59FF; }
|
| 149 |
</style>
|
| 150 |
</head>
|
| 151 |
<body>
|
| 152 |
-
<
|
| 153 |
-
|
| 154 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 160 |
-
|
| 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(
|
| 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 |
-
|
| 283 |
-
|
| 284 |
-
}
|
| 285 |
body {
|
| 286 |
box-sizing: border-box !important;
|
| 287 |
width: 100vw !important;
|
| 288 |
margin: 0 !important;
|
| 289 |
-
padding-top:
|
| 290 |
-
padding-left:
|
| 291 |
overflow-x: hidden !important;
|
| 292 |
}
|
| 293 |
#__insightux_toolbar {
|
| 294 |
-
position:fixed; top:0; left:0; right:0; height:
|
| 295 |
-
display:flex; align-items:center; gap:
|
| 296 |
-
|
| 297 |
-
border-bottom:
|
| 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
|
| 302 |
-
flex:0 0 auto; width:
|
| 303 |
-
background:#241f2e; border:1px solid #3a3348; color:#f0ecf7; font-size:15px;
|
| 304 |
-
display:flex; align-items:center; justify-content:center;
|
| 305 |
}
|
| 306 |
-
#
|
|
|
|
|
|
|
|
|
|
| 307 |
#__iux_addr_form { flex:1 1 auto; min-width:0; }
|
| 308 |
#__iux_addr {
|
| 309 |
-
width:100%; box-sizing:border-box; padding:
|
| 310 |
-
border:1.5px solid
|
| 311 |
-
font-size:12.5px;
|
| 312 |
}
|
| 313 |
-
#__iux_addr:focus { border-color:
|
| 314 |
-
#
|
| 315 |
-
|
| 316 |
-
|
| 317 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
#__insightux_sidebar {
|
| 319 |
-
position:fixed; top:
|
| 320 |
-
display:flex; flex-direction:column; align-items:stretch; gap:8px; padding:
|
| 321 |
-
box-sizing:border-box; font:11px -
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
}
|
| 326 |
-
#__insightux_sidebar
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
| 329 |
}
|
| 330 |
-
#__insightux_sidebar
|
| 331 |
-
#
|
| 332 |
-
#__insightux_sidebar .
|
| 333 |
-
|
| 334 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
#__insightux_update {
|
| 336 |
display:none; cursor:pointer; flex:0 0 auto; white-space:nowrap;
|
| 337 |
-
padding:
|
| 338 |
-
background:
|
| 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">
|
| 348 |
-
<button type="button" id="__iux_fwd" title="Forward">
|
| 349 |
-
<button type="button" id="__iux_reload" title="Reload">
|
| 350 |
-
<button type="button" id="__iux_home" title="Home">
|
| 351 |
-
<
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
<span id="__insightux_update" title="Click to open the download page"></span>
|
| 354 |
-
<span id="__insightux_status">Press S to start
|
|
|
|
| 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">
|
| 362 |
-
<button type="button" id="__iux_validate">
|
| 363 |
-
<button type="button" id="__iux_session">
|
| 364 |
-
<div class="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 381 |
-
|
| 382 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
};
|
| 384 |
window.insightuxShowUpdate = function(version, url){
|
| 385 |
const el = document.getElementById('__insightux_update');
|
| 386 |
if (!el) return;
|
| 387 |
-
el.
|
| 388 |
-
el.style.display = 'inline-
|
| 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.
|
| 657 |
-
ctx.
|
| 658 |
-
ctx.strokeStyle = '#
|
| 659 |
-
ctx.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 660 |
const label = activeBox.label;
|
| 661 |
-
ctx.font = '
|
| 662 |
-
const tw = ctx.measureText(label).width +
|
| 663 |
-
const ly = Math.max(0, activeBox.y -
|
| 664 |
-
ctx.
|
| 665 |
-
ctx.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
ctx.fillStyle = '#FFFFFF';
|
| 667 |
-
ctx.fillText(label, activeBox.x +
|
|
|
|
| 668 |
}
|
| 669 |
|
|
|
|
|
|
|
| 670 |
ctx.beginPath();
|
| 671 |
-
ctx.arc(dot.x, dot.y,
|
| 672 |
-
ctx.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 673 |
ctx.fill();
|
|
|
|
|
|
|
| 674 |
ctx.beginPath();
|
| 675 |
-
ctx.arc(dot.x, dot.y,
|
| 676 |
-
ctx.
|
|
|
|
|
|
|
| 677 |
ctx.fill();
|
| 678 |
-
ctx.lineWidth =
|
| 679 |
-
ctx.strokeStyle = 'rgba(
|
| 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,
|
| 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:
|
| 1019 |
-
|
| 1020 |
-
|
| 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
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
#__insightux_mouse_panel .
|
| 1029 |
-
#__insightux_mouse_panel
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
}
|
| 1033 |
-
#__insightux_mouse_panel
|
| 1034 |
-
#__insightux_mouse_panel .timer { font-weight: 600; margin-bottom: 6px; }
|
| 1035 |
#__insightux_mouse_panel .hero {
|
| 1036 |
-
background:
|
| 1037 |
-
|
|
|
|
| 1038 |
}
|
| 1039 |
-
#__insightux_mouse_panel .hero .lbl { font-size: 9px; text-transform: uppercase; color:
|
| 1040 |
-
#__insightux_mouse_panel .hero .el { font-weight:
|
| 1041 |
#__insightux_mouse_panel .interest-item, #__insightux_mouse_panel .log-item {
|
| 1042 |
-
background:
|
| 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">
|
| 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')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 & 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 · H heatmap · M panel · 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 · H · M · 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 |
+
"""
|