Insight_UX_1.0 / browser_session.py
Aryaman25's picture
Update Insight_UX_1.0 with latest changes: add auth, participants, tests, and dev requirements
f9609df
Raw
History Blame Contribute Delete
193 kB
"""
browser_session.py
InsightUX research browser.
Flow:
1. Window opens on real google.com — search and click through like a
normal browser (no scraping, no fake results — it's just Google).
2. Land on whatever page you want to study. Press S to start tracking.
3. Gaze dot + AOI highlighting (same as run_session.py) kicks in and
logs sessions/<timestamp>/gaze_log.jsonl + dom_log.jsonl.
4. Press E to stop. The window auto-navigates to a generated
analysis_report.html: ranked attention, dwell timeline, heatmap.
Prerequisite:
calibration.pkl must exist in the working directory (run calibrate.py first).
Run:
python browser_session.py
"""
import os
import io
import re
import sys
import csv
import hmac
import json
import time
import math
import base64
import logging
import secrets
import zipfile
import functools
import threading
import subprocess
from dataclasses import replace
from datetime import datetime
from logging.handlers import RotatingFileHandler
# Windows consoles default to a non-UTF-8 codepage (cp1252) — a stray
# unicode character in any print() (ours, or one of calibrate.py/validate.py's
# once relaunched as a subprocess sharing this process's stdout handle) would
# otherwise crash the whole process with UnicodeEncodeError. Confirmed this
# happened for real during a calibration run mid-session.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
import cv2
import numpy as np
import webview
import pyautogui
from PIL import Image
from preprocessing.preprocessing_pipeline import (
create_face_mesh,
estimate_camera_matrix,
estimate_head_pose,
compute_iris_radius,
compute_ear,
step1_normalize,
step2_illumination,
LEFT_EYE_INDICES,
LEFT_EAR_INDICES,
LEFT_IRIS_INDICES,
RIGHT_EYE_INDICES,
RIGHT_EAR_INDICES,
RIGHT_IRIS_INDICES,
)
from inference_pipeline import InsightUXPipeline, GazeAngleSmoother
from session_logger import GazeLogger
from analysis import generate_report, load_session, session_summary, summarize_mouse
import theme
import auth
import participants
# =============================================================================
# CONFIG — kept identical to run_session.py so calibration.pkl stays valid
# =============================================================================
# Bundled read-only assets (models/checkpoints) vs. per-user writable data
# (calibration.pkl, sessions/, the generated landing page) need different
# roots once frozen by PyInstaller — onedir nests bundled data under
# _internal/, alongside a persistent folder holding InsightUX.exe itself.
if getattr(sys, "frozen", False):
RESOURCE_DIR = sys._MEIPASS
DATA_DIR = os.path.dirname(sys.executable)
else:
RESOURCE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = RESOURCE_DIR
BASE_DIR = DATA_DIR # kept as an alias: subprocess cwd for calibrate/validate relaunches
# =============================================================================
# LOGGING — every "[browser_session] ..." diagnostic below goes through
# _log() instead of a bare print(): still prints to the console exactly as
# before (nothing lost for interactive `python browser_session.py` use),
# but is now ALSO durable in DATA_DIR/logs/insightux.log (rotated at 2MB,
# 3 backups kept) so a crash report has something to inspect afterwards
# instead of only whatever was still in a terminal's scrollback.
# =============================================================================
_LOG_DIR = os.path.join(DATA_DIR, "logs")
os.makedirs(_LOG_DIR, exist_ok=True)
_logger = logging.getLogger("insightux")
_logger.setLevel(logging.INFO)
_logger.propagate = False # this is the only handler we want -- don't also fall through to the root logger's default stderr handler
_log_handler = RotatingFileHandler(
os.path.join(_LOG_DIR, "insightux.log"), maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8"
)
_log_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
_logger.addHandler(_log_handler)
def _log(msg):
print(msg)
try:
_logger.info(msg)
except Exception:
pass # a full/unwritable disk shouldn't take the console message down with it
# Bump alongside packaging/installer.iss's AppVersion on every release, and
# update version.json in the same commit — see packaging/README.md.
VERSION = "1.0.0"
VERSION_CHECK_URL = "https://huggingface.co/arpitasethiii/insightux/resolve/main/version.json"
ONNX_PATH = os.path.join(RESOURCE_DIR, "models", "gaze_cnn_v4.onnx")
CALIBRATION_PATH = os.path.join(DATA_DIR, "calibration.pkl")
SCREEN_W, SCREEN_H = pyautogui.size()
PATCH_SOURCE = "blended"
POSE_SMOOTH = 0.65
MAX_JUMP = 220
NO_FACE_RESET = 15
DOM_EVERY = 6
EURO_MINCUTOFF = 0.35
EURO_BETA = 0.12
# Rolling-median window applied to (pitch, yaw) BEFORE the RBF query.
# See GazeAngleSmoother in inference_pipeline.py for why this is essential
# and not merely a nicety. Must match validate.py to stay comparable.
ANGLE_SMOOTH_WINDOW = 10
POSE_NORM_SCALE = 30.0
HEAD_PITCH_COMPENSATION = 0.0 # reverted — 0.35 made accuracy worse, not better
HEAD_YAW_COMPENSATION = 0.0
SESSIONS_ROOT = os.path.join(DATA_DIR, "sessions")
# Every session folder is named by browser_session.py itself in this exact
# shape (see start_tracking()'s datetime.now().strftime("%Y%m%d_%H%M%S")) —
# used to validate session ids handed back from JS (rename/open-report calls)
# before joining them onto a filesystem path, since window.pywebview.api is
# reachable from any page's own script, not just our injected chrome.
_SESSION_ID_RE = re.compile(r"^\d{8}_\d{6}$")
# pywebview binds js_api to the whole window, not per-frame -- every page
# the user browses (not just our own landing/report/login pages) gets a
# live window.pywebview.api, so a real website's OWN script could otherwise
# call start_tracking()/delete_my_session()/quit_app()/etc. directly, no
# click of ours involved. A per-origin allowlist isn't possible here (the
# app's whole point is that tracking must work on arbitrary real websites,
# so "which site is loaded" can't be the gate) -- instead every state-
# changing Api method below requires this token as its trailing argument.
# It's baked into CHROME_JS as a closure-local const (see __IUX_TOK__ in
# CHROME_JS below) that's never assigned to `window`, so a hosting page's
# own top-level script has no way to read it back out and forge a call.
# Regenerated fresh every process start; never needs to survive a restart.
_API_TOKEN = secrets.token_hex(24)
def _require_token(fn):
"""Decorates every JS-reachable Api method that mutates state or reads
otherwise-private data. The token always arrives as the CALLER'S LAST
positional argument (CHROME_JS appends it to every window.pywebview.api
call it makes) -- stripped here before the wrapped method ever sees its
normal argument list, so method bodies below stay unaware this exists.
login()/create_profile()/list_profiles() are deliberately NOT wrapped:
they're only ever called from the login page's own self-contained
script, which (unlike CHROME_JS) never runs alongside third-party page
content in the first place. log_mouse_data() is also left unwrapped --
see its own docstring for why that one's a deliberately accepted gap.
Rejecting returns None, which every existing call site already treats
as "nothing happened" (`res && res.ok`, `if (ok)`, or no .then() at
all) -- no call site needed to change to tolerate this."""
@functools.wraps(fn)
def wrapper(self, *args):
token = args[-1] if args else None
if not isinstance(token, str) or not hmac.compare_digest(token, _API_TOKEN):
_log(f"[browser_session] rejected {fn.__name__}() -- missing/invalid API token "
f"(a page's own script calling window.pywebview.api directly?)")
return None
return fn(self, *args[:-1])
return wrapper
# =============================================================================
# MULTI-USER PROFILES
#
# CALIBRATION_PATH/SESSIONS_ROOT above were, before profiles existed, the
# one-and-only global data locations — and on an install that already has
# data there, they still describe exactly where it lives. _apply_user_paths()
# below repoints these same two names at a per-profile folder the moment
# someone logs in, and every existing call site (gaze_worker(),
# _sessions_json(), the calibration-exists check in start_tracking(),
# the os.makedirs at boot, ...) keeps reading them completely unchanged —
# isolation is a side effect of *which folder these constants point to*,
# not a rewrite of the code that uses them.
# =============================================================================
_LEGACY_CALIBRATION_PATH = CALIBRATION_PATH
_LEGACY_SESSIONS_ROOT = SESSIONS_ROOT
_LEGACY_BASELINE_POSE_PATH = os.path.join(DATA_DIR, "baseline_pose.pkl")
CURRENT_USER_DIR = None # the signed-in owner's own folder; None until login/switch/create
CURRENT_SUBJECT_DIR = None # the ACTIVE TRACKING SUBJECT's folder — owner's own dir in "self"
# mode, or users/<owner>/participants/<id>/ when tracking a
# participant. SESSIONS_ROOT/CALIBRATION_PATH/_user_onnx_path()
# all derive from this one value, never from CURRENT_USER_DIR
# directly, so "who owns this account" and "who is being tracked
# right now" stay two separately-settable things.
def _user_onnx_path():
"""The active tracking subject's own fine-tuned gaze model if they've
ever calibrated with fine-tuning on, else the shared bundled default —
mirrors the write side of this in calibrate.py's out_onnx_path. A
subject (owner or participant) who has never calibrated is correctly
treated as running on the stock model, never on someone else's."""
if CURRENT_SUBJECT_DIR:
candidate = os.path.join(CURRENT_SUBJECT_DIR, "gaze_cnn_v4_finetuned.onnx")
if os.path.exists(candidate):
return candidate
return ONNX_PATH
def _apply_user_paths(user_id):
"""Owner-level redirection only — sets CURRENT_USER_DIR to this
profile's own folder. Session/calibration paths for the ACTIVE
TRACKING SUBJECT are computed separately by _apply_subject_paths(),
which the caller (Api._activate_user()) always calls right after this
(defaulting to self) — see that function for why the two are split."""
global CURRENT_USER_DIR
CURRENT_USER_DIR = auth.user_dir(DATA_DIR, user_id)
os.makedirs(CURRENT_USER_DIR, exist_ok=True)
def _apply_subject_paths(participant_record):
"""The one place SESSIONS_ROOT/CALIBRATION_PATH get repointed at the
active tracking subject's own folder — participant_record=None means
"Myself" (the owner's own folder, exactly the pre-participants
behavior), a participant dict means their nested
users/<owner>/participants/<id>/ folder. Every existing call site that
reads these two names (gaze_worker(), _sessions_json(), the
calibration-exists check in start_tracking(), ...) needed zero
changes — same reasoning _apply_user_paths() already documents."""
global SESSIONS_ROOT, CALIBRATION_PATH, CURRENT_SUBJECT_DIR
if participant_record:
CURRENT_SUBJECT_DIR = participants.participant_dir(CURRENT_USER_DIR, participant_record["id"])
else:
CURRENT_SUBJECT_DIR = CURRENT_USER_DIR
SESSIONS_ROOT = os.path.join(CURRENT_SUBJECT_DIR, "sessions")
CALIBRATION_PATH = os.path.join(CURRENT_SUBJECT_DIR, "calibration.pkl")
os.makedirs(SESSIONS_ROOT, exist_ok=True)
def _migrate_legacy_data(user_id):
"""Runs exactly once, only for the very first profile ever created on
an install that already had pre-profile global data — folds that
existing calibration/sessions history into the new profile instead of
silently orphaning it. Never touched again for the 2nd+ profile."""
import shutil
dest_dir = auth.user_dir(DATA_DIR, user_id)
os.makedirs(dest_dir, exist_ok=True)
if os.path.exists(_LEGACY_CALIBRATION_PATH):
dest = os.path.join(dest_dir, "calibration.pkl")
if not os.path.exists(dest):
shutil.move(_LEGACY_CALIBRATION_PATH, dest)
if os.path.exists(_LEGACY_BASELINE_POSE_PATH):
dest = os.path.join(dest_dir, "baseline_pose.pkl")
if not os.path.exists(dest):
shutil.move(_LEGACY_BASELINE_POSE_PATH, dest)
if os.path.isdir(_LEGACY_SESSIONS_ROOT):
dest_sessions = os.path.join(dest_dir, "sessions")
os.makedirs(dest_sessions, exist_ok=True)
for name in os.listdir(_LEGACY_SESSIONS_ROOT):
src = os.path.join(_LEGACY_SESSIONS_ROOT, name)
dst = os.path.join(dest_sessions, name)
if not os.path.exists(dst):
shutil.move(src, dst)
# The window opens on this local, fully InsightUX-branded page instead of
# raw google.com. What you type here still hits REAL Google — no fake
# results, no scraping — this page is just the visible skin around that
# one redirect step. Typed URLs go straight there instead.
_LANDING_HTML = r"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="insightux-landing" content="true">
<title>InsightUX — Eye-Tracking Research Browser</title>
<style>
__THEME_CSS__
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 22px;
background: var(--iux-page-bg);
font-family: var(--iux-font); color: var(--iux-text);
transition: background 0.3s var(--iux-ease), color 0.3s var(--iux-ease);
overflow: hidden; /* the page itself never scrolls — only .recent-list does */
padding: 16px 0;
}
.brand { text-align: center; }
.logo-row { display: flex; align-items: center; justify-content: center; gap: 12px; }
.logo-mark {
width: 46px; height: 46px; border-radius: 14px; background: var(--iux-accent-grad);
display: flex; align-items: center; justify-content: center; box-shadow: var(--iux-shadow-glow);
}
.logo-mark svg { color: var(--iux-on-accent); }
.logo {
font-size: 40px; font-weight: 700; letter-spacing: -0.02em;
background: var(--iux-accent-grad);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.tagline { color: var(--iux-text-dim); font-size: 14.5px; margin-top: 8px; }
.desc { color: var(--iux-text-faint); font-size: 12.5px; margin-top: 4px; max-width: 480px; }
form { width: 560px; max-width: 88vw; position: relative; }
form .search-icon {
position: absolute; left: 18px; top: 50%; transform: translateY(-50%); color: var(--iux-text-faint);
pointer-events: none;
}
input {
width: 100%; padding: 16px 20px 16px 46px; font-size: 15px; border-radius: 28px;
border: 1.5px solid var(--iux-border); background: var(--iux-surface); color: var(--iux-text);
outline: none; transition: border-color 0.15s var(--iux-ease), box-shadow 0.15s var(--iux-ease);
box-shadow: var(--iux-shadow-sm);
}
input:focus { border-color: var(--iux-primary-light); box-shadow: var(--iux-shadow-glow); }
input::placeholder { color: var(--iux-text-faint); }
.chips { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; max-width: 640px; }
.chip {
display: flex; align-items: center; gap: 8px; padding: 8px 14px; border-radius: 999px;
background: var(--iux-surface); border: 1px solid var(--iux-border); font-size: 12px; color: var(--iux-text-dim);
}
.chip kbd {
background: var(--iux-surface-hi); border: 1px solid var(--iux-border); border-radius: 6px;
padding: 1px 7px; font: 700 11px var(--iux-font); color: var(--iux-primary-light);
}
.hint {
color: var(--iux-text-faint); text-align: center; line-height: 1.6; font-size: 11.5px; max-width: 520px;
}
</style>
</head>
<body>
<div class="brand iux-fade-in">
<div class="logo-row">
<div class="logo-mark">__LOGO_ICON__</div>
<div class="logo">InsightUX</div>
</div>
<div class="tagline">AI-Powered Eye &amp; Mouse Tracking Research Browser</div>
<div class="desc">Browse the real web, then press S to start studying attention — gaze, cursor, and clicks, all in one session.</div>
</div>
<form id="searchForm" class="iux-fade-in">
<span class="search-icon">__SEARCH_ICON__</span>
<input id="searchInput" type="text" autofocus
placeholder="Search Google, or enter a website address">
</form>
<div class="chips iux-fade-in">
<span class="chip">__PLAY_ICON__ <kbd>S</kbd> Start Tracking</span>
<span class="chip">__STOP_ICON__ <kbd>E</kbd> Stop Tracking</span>
<span class="chip">__LAYERS_ICON__ <kbd>H</kbd> Heatmap</span>
<span class="chip">__CURSOR_ICON__ <kbd>M</kbd> Mouse Panel</span>
</div>
<div class="hint">
Search results and websites open below as normal. Once you land on the page you want
to study, press <b>S</b> to start eye-tracking, <b>E</b> to stop.
</div>
<script>
__ICONS_JS__
__THEME_JS__
document.getElementById('searchForm').addEventListener('submit', function(e){
e.preventDefault();
const raw = document.getElementById('searchInput').value.trim();
if (!raw) return;
const looksLikeUrl = /^https?:\/\//i.test(raw) ||
(/^[\w-]+(\.[\w-]+)+([/?#].*)?$/i.test(raw) && !raw.includes(' '));
if (looksLikeUrl) {
window.location.href = raw.startsWith('http') ? raw : ('https://' + raw);
} else {
window.location.href = 'https://www.google.com/search?q=' + encodeURIComponent(raw);
}
});
</script>
</body>
</html>
"""
def _session_thumbnail_data_uri(session_dir, max_size=(96, 64)):
"""Small base64 data: URI thumbnail from this session's own first
captured screenshot, if any. A plain file:// <img src> would silently
fail to load whenever the containing page is a real http(s) website
(Chromium blocks local-file subresource loads from non-file:// pages)
— the same class of problem Api._open_session_report() already works
around for "View Report" links, and exactly what analysis.py's own
_screenshot_data_uri() already sidesteps the same way for the report
page itself. Resized + JPEG-recompressed and cached as screenshots/
_thumb.jpg next to the full-resolution PNGs (leading underscore keeps
it out of the shot_NNNN.png sequence analysis.py reads by exact
filename, never a directory listing, so this can't confuse it) so a
session list of many entries only pays the resize cost once, not on
every single time My Sessions/Persons is opened."""
screens_dir = os.path.join(session_dir, "screenshots")
thumb_path = os.path.join(screens_dir, "_thumb.jpg")
if not os.path.exists(thumb_path):
if not os.path.isdir(screens_dir):
return None
shot_names = sorted(n for n in os.listdir(screens_dir) if n.lower().endswith(".png"))
if not shot_names:
return None
try:
img = Image.open(os.path.join(screens_dir, shot_names[0]))
img.thumbnail(max_size)
img.convert("RGB").save(thumb_path, format="JPEG", quality=60)
except Exception:
return None
try:
with open(thumb_path, "rb") as f:
return "data:image/jpeg;base64," + base64.b64encode(f.read()).decode("ascii")
except Exception:
return None
def _sessions_json(root, limit=25):
"""Read-only directory listing of sessions/<timestamp> folders under
an explicitly-given root — the shared engine behind Profile -> My
Sessions and (Three-dot) Persons -> a specific participant's history.
Taking root as a parameter rather than always reading the global
SESSIONS_ROOT is what lets both show the *right* folder regardless of
whoever the Participant panel currently has selected. Never touches
session contents or tracking state.
Each entry is matched to its own already-generated analysis_report.html
(written by generate_report() at session end, under that same session's
folder — the existing session-id -> report mapping already used
elsewhere) so "View Report" always opens THAT session's report, never
just the most recent one. Duration/sample-count/domain reuse analysis.py's
existing load_session()/session_summary() — same read-only computation
the report itself already does, not a new data path."""
import datetime as _dt
from urllib.parse import urlparse
entries = []
if os.path.isdir(root):
for name in os.listdir(root):
full = os.path.join(root, name)
if not os.path.isdir(full):
continue
try:
ts = _dt.datetime.strptime(name, "%Y%m%d_%H%M%S")
except ValueError:
continue
entries.append((ts, name, full))
entries.sort(key=lambda triple: triple[0], reverse=True)
out = []
for ts, name, full in entries[:limit]:
report_path = os.path.join(full, "analysis_report.html")
if not os.path.exists(report_path):
# A report can fail to build once (e.g. a transient issue right
# as tracking stopped) even though the underlying session data
# is completely intact — self-heal here instead of leaving that
# session permanently unreachable. Reuses the exact same
# generate_report() call gaze_worker() already makes at session
# end; no new report-building logic.
try:
generate_report(full)
except Exception as e:
_log(f"[browser_session] could not (re)build report for {name}: {e}")
report_url = None
if os.path.exists(report_path):
report_url = "file://" + report_path.replace(os.sep, "/")
domain, duration, samples = None, None, None
try:
gaze, dom = load_session(full)
summary = session_summary(gaze, dom)
duration = summary["duration"] or None
samples = summary["samples"] or None
if summary["url"]:
domain = urlparse(summary["url"]).netloc or summary["url"]
except Exception:
pass # malformed/partial session folder — still list it, just without stats
custom_name = None
try:
with open(os.path.join(full, "session_meta.json"), "r", encoding="utf-8") as f:
custom_name = (json.load(f).get("display_name") or "").strip() or None
except Exception:
pass # no session_meta.json (legacy session) or unreadable — no custom name
thumbnail = _session_thumbnail_data_uri(full)
out.append({
"id": name, # stable session identifier (its own timestamp folder name) — used to key rename/report calls, never shown directly
"label": ts.strftime("%b %d, %Y — %I:%M %p").replace(" 0", " "),
"customName": custom_name,
"when": ts.strftime("%Y-%m-%d %H:%M"),
"domain": domain,
"duration": duration,
"samples": samples,
"reportUrl": report_url,
"thumbnail": thumbnail,
})
return out
def _write_landing_page():
path = os.path.join(DATA_DIR, "insightux_landing.html")
html = _LANDING_HTML
html = html.replace("__THEME_CSS__", theme.THEME_CSS)
html = html.replace("__THEME_JS__", theme.THEME_TOGGLE_JS)
html = html.replace("__ICONS_JS__", theme.ICONS_JS)
html = html.replace("__LOGO_ICON__", theme.icon("eye", 24))
html = html.replace("__SEARCH_ICON__", theme.icon("search", 18))
html = html.replace("__PLAY_ICON__", theme.icon("play", 13))
html = html.replace("__STOP_ICON__", theme.icon("stop", 13))
html = html.replace("__LAYERS_ICON__", theme.icon("layers", 13))
html = html.replace("__CURSOR_ICON__", theme.icon("cursor", 13))
with open(path, "w", encoding="utf-8") as f:
f.write(html)
return "file://" + path.replace(os.sep, "/")
LANDING_URL = _write_landing_page()
# =============================================================================
# LOGIN / PROFILE PICKER — the window opens HERE, not on LANDING_URL, so a
# password is always required before any profile's data can be reached.
# No login state is persisted across app restarts.
# =============================================================================
_LOGIN_HTML = r"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="insightux-login" content="true">
<title>InsightUX — Sign in</title>
<style>
__THEME_CSS__
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 20px;
background: var(--iux-page-bg);
font-family: var(--iux-font); color: var(--iux-text);
transition: background 0.3s var(--iux-ease), color 0.3s var(--iux-ease);
overflow: hidden;
padding: 16px 0;
}
.theme-toggle { position: fixed; top: 20px; right: 22px; width: 38px; height: 38px; padding: 0; }
.brand { text-align: center; }
.logo-row { display: flex; align-items: center; justify-content: center; gap: 12px; }
.logo-mark {
width: 42px; height: 42px; border-radius: 13px; background: var(--iux-accent-grad);
display: flex; align-items: center; justify-content: center; box-shadow: var(--iux-shadow-glow);
}
.logo-mark svg { color: var(--iux-on-accent); }
.logo {
font-size: 32px; font-weight: 700; letter-spacing: -0.02em;
background: var(--iux-accent-grad);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.tagline { color: var(--iux-text-dim); font-size: 13px; margin-top: 6px; }
.auth-card { width: 380px; max-width: 90vw; padding: 22px; display: flex; flex-direction: column; gap: 14px; }
.auth-tabs { display: flex; gap: 6px; background: var(--iux-surface-hi); padding: 3px; border-radius: 999px; }
.auth-tab {
flex: 1 1 auto; border: none; background: transparent; padding: 8px; border-radius: 999px;
color: var(--iux-text-dim); font: 600 12.5px var(--iux-font); cursor: pointer;
}
.auth-tab.on { background: var(--iux-accent-grad); color: var(--iux-on-accent); }
.profile-list { display: flex; flex-direction: column; gap: 6px; max-height: 260px; overflow-y: auto; }
.profile-row {
display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px;
border: 1px solid var(--iux-border); cursor: pointer; transition: background .12s ease;
}
.profile-row:hover { background: var(--iux-surface-hi); }
.profile-row .avatar {
width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0;
background: var(--iux-accent-grad); color: var(--iux-on-accent);
display: flex; align-items: center; justify-content: center; font: 700 13px var(--iux-font);
}
.profile-row .who { min-width: 0; flex: 1 1 auto; }
.profile-row .name {
font: 600 12.5px var(--iux-font); color: var(--iux-text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.profile-row .email {
font-size: 11px; color: var(--iux-text-faint);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.field { display: flex; flex-direction: column; gap: 5px; }
.field label { font-size: 11px; color: var(--iux-text-faint); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
.field input {
padding: 10px 12px; font-size: 13.5px; border-radius: 10px;
border: 1.5px solid var(--iux-border); background: var(--iux-surface); color: var(--iux-text);
outline: none; transition: border-color .15s var(--iux-ease), box-shadow .15s var(--iux-ease);
}
.field input:focus { border-color: var(--iux-primary-light); box-shadow: var(--iux-shadow-glow); }
.auth-error {
display: none; font-size: 12px; color: var(--iux-danger); background: rgba(251,113,133,0.1);
border: 1px solid rgba(251,113,133,0.3); border-radius: 8px; padding: 8px 10px;
}
.auth-error.on { display: block; }
.auth-submit {
padding: 11px; border-radius: 10px; border: none; background: var(--iux-accent-grad);
color: var(--iux-on-accent); font: 700 13px var(--iux-font); cursor: pointer;
transition: transform .15s var(--iux-ease), box-shadow .15s var(--iux-ease);
}
.auth-submit:hover { box-shadow: var(--iux-shadow-glow); }
.auth-submit:disabled { opacity: .6; cursor: default; }
.auth-back { background: transparent; border: none; color: var(--iux-text-faint); font-size: 11.5px; cursor: pointer; text-align: left; padding: 0; }
.auth-back:hover { color: var(--iux-text); }
.auth-empty { font-size: 12px; color: var(--iux-text-faint); text-align: center; padding: 18px 0; }
.auth-hint { color: var(--iux-text-faint); font-size: 11px; text-align: center; max-width: 380px; }
</style>
</head>
<body>
<button type="button" class="theme-toggle iux-btn" id="themeToggle" title="Toggle theme"></button>
<div class="brand iux-fade-in">
<div class="logo-row">
<div class="logo-mark">__LOGO_ICON__</div>
<div class="logo">InsightUX</div>
</div>
<div class="tagline">Sign in to your profile to continue</div>
</div>
<div class="auth-card iux-card iux-fade-in">
<div class="auth-tabs">
<button type="button" class="auth-tab on" id="tabLogin">Log in</button>
<button type="button" class="auth-tab" id="tabCreate">Create profile</button>
</div>
<div id="loginPane">
<div id="profileListWrap">
<div class="profile-list" id="profileList"></div>
</div>
<div id="passwordWrap" style="display:none; flex-direction:column; gap:14px;">
<button type="button" class="auth-back" id="backToList">&larr; Back to profiles</button>
<div class="field">
<label id="passwordForLabel">Password</label>
<input id="loginPassword" type="password" autocomplete="current-password" placeholder="Enter your password">
</div>
<div class="auth-error" id="loginError"></div>
<button type="button" class="auth-submit" id="loginSubmit">Log in</button>
</div>
</div>
<div id="createPane" style="display:none; flex-direction:column; gap:14px;">
<div class="field"><label>Name</label><input id="createName" type="text" autocomplete="name" placeholder="Your name"></div>
<div class="field"><label>Email</label><input id="createEmail" type="email" autocomplete="email" placeholder="you@example.com"></div>
<div class="field"><label>Password</label><input id="createPassword" type="password" autocomplete="new-password" placeholder="At least 6 characters"></div>
<div class="auth-error" id="createError"></div>
<button type="button" class="auth-submit" id="createSubmit">Create profile</button>
</div>
</div>
<div class="auth-hint">Each profile's sessions, calibration, and reports are private to that profile — no other profile on this device can see them.</div>
<script>
__ICONS_JS__
__THEME_JS__
document.getElementById('themeToggle').innerHTML = iuxIcon(window.insightuxGetTheme() === 'light' ? 'moon' : 'sun', 18);
document.getElementById('themeToggle').addEventListener('click', function(){
const next = window.insightuxToggleTheme();
this.innerHTML = iuxIcon(next === 'light' ? 'moon' : 'sun', 18);
});
// profile name/email are free text from create_profile() — escape before
// innerHTML so a crafted profile name can't run script on this page.
function escapeHtml(str){
return String(str).replace(/[&<>"']/g, function(c){
return { '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c];
});
}
const PROFILES = __PROFILES_JSON__;
let selectedProfile = null;
function renderProfiles(){
const list = document.getElementById('profileList');
if (!PROFILES.length) {
list.innerHTML = '<div class="auth-empty">No profiles yet on this device — create one to get started.</div>';
return;
}
list.innerHTML = PROFILES.map(function(p, i){
return '<div class="profile-row" data-idx="' + i + '">' +
'<span class="avatar">' + escapeHtml(p.avatar) + '</span>' +
'<div class="who"><div class="name">' + escapeHtml(p.name) + '</div><div class="email">' + escapeHtml(p.email) + '</div></div>' +
'</div>';
}).join('');
Array.from(list.children).forEach(function(row, i){
row.addEventListener('click', function(){ selectProfile(PROFILES[i]); });
});
}
renderProfiles();
function selectProfile(p){
selectedProfile = p;
document.getElementById('profileListWrap').style.display = 'none';
document.getElementById('passwordWrap').style.display = 'flex';
document.getElementById('passwordForLabel').textContent = 'Password for ' + p.name;
const pwInput = document.getElementById('loginPassword');
pwInput.value = '';
document.getElementById('loginError').classList.remove('on');
pwInput.focus();
}
document.getElementById('backToList').addEventListener('click', function(){
document.getElementById('passwordWrap').style.display = 'none';
document.getElementById('profileListWrap').style.display = 'block';
selectedProfile = null;
});
function showError(id, msg){
const el = document.getElementById(id);
el.textContent = msg;
el.classList.add('on');
}
function doLogin(){
if (!selectedProfile || !window.pywebview) return;
const pw = document.getElementById('loginPassword').value;
const btn = document.getElementById('loginSubmit');
btn.disabled = true; btn.textContent = 'Signing in...';
window.pywebview.api.login(selectedProfile.id, pw).then(function(res){
if (!res || !res.ok) {
btn.disabled = false; btn.textContent = 'Log in';
showError('loginError', (res && res.error) || 'Could not sign in.');
}
// On success Python itself navigates the window -- nothing else to do here.
});
}
document.getElementById('loginSubmit').addEventListener('click', doLogin);
document.getElementById('loginPassword').addEventListener('keydown', function(e){ if (e.key === 'Enter') doLogin(); });
function doCreate(){
if (!window.pywebview) return;
const name = document.getElementById('createName').value.trim();
const email = document.getElementById('createEmail').value.trim();
const pw = document.getElementById('createPassword').value;
const btn = document.getElementById('createSubmit');
btn.disabled = true; btn.textContent = 'Creating...';
window.pywebview.api.create_profile(name, email, pw).then(function(res){
if (!res || !res.ok) {
btn.disabled = false; btn.textContent = 'Create profile';
showError('createError', (res && res.error) || 'Could not create profile.');
}
});
}
document.getElementById('createSubmit').addEventListener('click', doCreate);
function showTab(which){
const login = which === 'login';
document.getElementById('tabLogin').classList.toggle('on', login);
document.getElementById('tabCreate').classList.toggle('on', !login);
document.getElementById('loginPane').style.display = login ? 'block' : 'none';
document.getElementById('createPane').style.display = login ? 'none' : 'flex';
}
document.getElementById('tabLogin').addEventListener('click', function(){ showTab('login'); });
document.getElementById('tabCreate').addEventListener('click', function(){ showTab('create'); });
if (!PROFILES.length) showTab('create');
</script>
</body>
</html>
"""
def _write_login_page():
path = os.path.join(DATA_DIR, "insightux_login.html")
html = _LOGIN_HTML
html = html.replace("__THEME_CSS__", theme.THEME_CSS)
html = html.replace("__THEME_JS__", theme.THEME_TOGGLE_JS)
html = html.replace("__ICONS_JS__", theme.ICONS_JS)
html = html.replace("__LOGO_ICON__", theme.icon("eye", 20))
html = html.replace("__PROFILES_JSON__", json.dumps(auth.list_profiles(DATA_DIR)))
with open(path, "w", encoding="utf-8") as f:
f.write(html)
return "file://" + path.replace(os.sep, "/")
LOGIN_URL = _write_login_page()
def normalize_pose(pitch_deg, yaw_deg, roll_deg):
return np.array([
pitch_deg / POSE_NORM_SCALE,
yaw_deg / POSE_NORM_SCALE,
roll_deg / POSE_NORM_SCALE,
], dtype=np.float32)
def compensate_pitch(raw_pitch, head_pitch_deg):
return raw_pitch - np.radians(head_pitch_deg) * HEAD_PITCH_COMPENSATION
def compensate_yaw(raw_yaw, head_yaw_deg):
return raw_yaw - np.radians(head_yaw_deg) * HEAD_YAW_COMPENSATION
# =============================================================================
# ONE EURO FILTER (display smoothing only)
# =============================================================================
class OneEuroFilter:
def __init__(self, mincutoff=0.8, beta=0.4, dcutoff=1.0):
self.mincutoff = mincutoff
self.beta = beta
self.dcutoff = dcutoff
self.x_prev = None
self.dx_prev = 0.0
self.t_prev = None
@staticmethod
def _alpha(cutoff, dt):
tau = 1.0 / (2 * math.pi * cutoff)
return 1.0 / (1.0 + tau / dt)
def __call__(self, x, t):
if self.x_prev is None:
self.x_prev, self.t_prev = x, t
return x
dt = t - self.t_prev
if dt <= 0:
dt = 1e-3
self.t_prev = t
dx = (x - self.x_prev) / dt
a_d = self._alpha(self.dcutoff, dt)
dx_hat = a_d * dx + (1 - a_d) * self.dx_prev
cutoff = self.mincutoff + self.beta * abs(dx_hat)
a = self._alpha(cutoff, dt)
x_hat = a * x + (1 - a) * self.x_prev
self.x_prev, self.dx_prev = x_hat, dx_hat
return x_hat
def reset(self):
self.x_prev = None
self.dx_prev = 0.0
self.t_prev = None
# =============================================================================
# JS: browser chrome (always present) — top toolbar (back/forward/reload/
# home/address bar) + left sidebar (Calibrate/Validate/Start Session), so the
# window reads as a normal browser instead of a bare content pane. Purely an
# HTML/CSS/JS overlay drawn on top of whatever page is loaded — pywebview
# 4.4.1 has no native toolbar API, so this is the only way to add persistent
# chrome without switching GUI frameworks.
#
# The page itself is pushed down/right by exactly the toolbar height and
# sidebar width (via forced <body> padding, not just floating the chrome on
# top of it) so real page content is never hidden underneath — the chrome
# and the website occupy clearly separate regions instead of overlapping.
# overflow-x is force-hidden as a safety net against the small width
# reduction ever introducing a horizontal scrollbar. The one edge case this
# can't fully solve: a small minority of sites use position:fixed elements
# of their own pinned to the true viewport edges (independent of body
# padding) — those can still end up visually behind our chrome.
# =============================================================================
CHROME_JS = r"""
(function(){
if (window.__insightuxControl) { return; }
window.__insightuxControl = true;
// Required as the trailing argument on every state-changing/private
// window.pywebview.api call below (see _require_token in browser_session.py
// for the full reasoning). A plain closure-local const, deliberately never
// assigned to `window` — pywebview exposes window.pywebview.api to every
// page's own script, not just this injected one, so a hostile website
// could otherwise call start_tracking()/delete_my_session()/quit_app()
// etc. directly. Because this is a normal JS closure variable, a page's
// own top-level script has no way to read it back out — only code that
// was actually part of THIS script (baked in by Python at injection
// time) can produce a valid call.
const __IUX_TOK__ = "__API_TOKEN__";
// S/E (start/stop tracking) only make sense on an actual website — not on
// the InsightUX landing/search page and not on a generated insights
// report page. The toolbar/sidebar, and X (quit), always work everywhere
// except the login page, which has no user/session context yet.
const isLanding = !!document.querySelector('meta[name="insightux-landing"]');
const isReport = !!document.querySelector('meta[name="insightux-report"]');
const isLogin = !!document.querySelector('meta[name="insightux-login"]');
const isTrackable = !isLanding && !isReport && !isLogin;
__ICONS_JS__
__THEME_JS__
// The report page (isReport) and the login page (isLogin) get none of
// this injected chrome — no toolbar, no sidebar, no reserved body
// padding — so each has the full window to itself. Only X-quit (further
// down, outside this guard) still works there, same as before.
if (!isReport && !isLogin) {
const style = document.createElement('style');
style.textContent = `
__THEME_CSS__
html { overflow-x: hidden !important; }
body {
box-sizing: border-box !important;
width: 100vw !important;
margin: 0 !important;
padding-top: 54px !important;
padding-left: 78px !important;
overflow-x: hidden !important;
}
#__insightux_toolbar.chrome-hidden { transform: translateY(-130%); }
#__insightux_sidebar.chrome-hidden { transform: translateX(-130%); }
#__insightux_toolbar {
position:fixed; top:0; left:0; right:0; height:54px; z-index:2147483647;
display:flex; align-items:center; gap:8px; padding:0 14px; box-sizing:border-box;
font:12px var(--iux-font); color:var(--iux-text);
border-bottom:1px solid var(--iux-border);
transition: transform .25s var(--iux-ease);
}
#__insightux_toolbar .iux-navbtn {
flex:0 0 auto; width:32px; height:32px; border-radius:10px; padding:0;
}
/* Tracking mode: shrink the toolbar down to status pills + a small
"reveal" handle so the tracked page gets the screen back, without
ever removing the controls — __iux_reveal_toggle brings them back. */
#__insightux_toolbar.tracking-compact { height:38px; gap:6px; padding:0 10px; }
#__insightux_toolbar.tracking-compact .iux-navbtn,
#__insightux_toolbar.tracking-compact #__iux_addr_wrap,
#__insightux_toolbar.tracking-compact #__insightux_update,
#__insightux_toolbar.tracking-compact #__insightux_status { display:none; }
#__insightux_toolbar.tracking-compact #__iux_reveal_toggle { display:flex !important; }
body.iux-tracking-compact { padding-top:38px !important; }
#__iux_addr_wrap {
flex:1 1 auto; min-width:0; position:relative; display:flex; align-items:center;
}
#__iux_addr_wrap .lead-icon { position:absolute; left:12px; color:var(--iux-text-faint); pointer-events:none; display:flex; }
#__iux_addr_form { flex:1 1 auto; min-width:0; }
#__iux_addr {
width:100%; box-sizing:border-box; padding:8px 14px 8px 36px; border-radius:999px;
border:1.5px solid var(--iux-border); background:var(--iux-surface); color:var(--iux-text); outline:none;
font-size:12.5px; transition:border-color .15s var(--iux-ease), box-shadow .15s var(--iux-ease);
}
#__iux_addr:focus { border-color:var(--iux-primary-light); box-shadow:var(--iux-shadow-glow); }
#__iux_loadbar {
position:fixed; top:0; left:0; height:2px; width:0; z-index:2147483647;
background:var(--iux-accent-grad); transition:width .5s var(--iux-ease), opacity .3s ease .25s; opacity:0;
}
#__insightux_indicators { display:flex; align-items:center; gap:6px; flex:0 0 auto; }
#__insightux_status.iux-pill { max-width:26vw; }
#__insightux_status .txt { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
#__iux_theme_toggle { flex:0 0 auto; }
#__insightux_sidebar {
position:fixed; top:66px; left:12px; bottom:12px; width:66px; z-index:2147483647;
display:flex; flex-direction:column; align-items:stretch; gap:8px; padding:12px 8px;
box-sizing:border-box; font:11px var(--iux-font);
border-radius:var(--iux-radius-lg);
transition:width .2s var(--iux-ease), transform .25s var(--iux-ease);
}
#__insightux_sidebar.collapsed { width:56px; }
#__insightux_sidebar .side-btn {
position:relative; flex-direction:column; gap:4px; padding:11px 4px; line-height:1.35;
font-size:10.5px; width:100%;
}
#__insightux_sidebar .side-btn.active {
background:var(--iux-accent-grad); border-color:transparent; color:var(--iux-on-accent); box-shadow:var(--iux-shadow-glow);
}
#__insightux_sidebar.collapsed .side-btn .lbl { display:none; }
#__insightux_sidebar .side-btn .tip {
position:absolute; left:100%; top:50%; transform:translateY(-50%) translateX(8px);
background:var(--iux-surface-hi); color:var(--iux-text); border:1px solid var(--iux-border);
padding:5px 10px; border-radius:8px; font-size:11px; white-space:nowrap; pointer-events:none;
opacity:0; transition:opacity .15s ease; box-shadow:var(--iux-shadow-sm);
}
#__insightux_sidebar.collapsed .side-btn:hover .tip { opacity:1; }
#__insightux_sidebar .spacer { margin-top:auto; }
#__insightux_sidebar .hints {
color:var(--iux-text-faint); font-size:9px; line-height:1.7; text-align:center; padding:2px 0;
}
#__insightux_sidebar.collapsed .hints { display:none; }
#__iux_collapse_btn { width:100%; }
#__iux_settings_pop {
position:absolute; left:100%; bottom:0; margin-left:10px; width:220px; padding:14px;
display:none; z-index:2147483647; border-radius:var(--iux-radius);
}
#__iux_settings_pop.open { display:block; }
#__iux_settings_pop h4 { margin:0 0 10px; font-size:12px; color:var(--iux-text); }
#__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); }
#__iux_settings_pop .kbd-row { display:flex; justify-content:space-between; font-size:11px; color:var(--iux-text-faint); padding:3px 0; }
#__iux_settings_pop .ver { margin-top:8px; font-size:10px; color:var(--iux-text-faint); text-align:center; }
#__iux_participant_pop {
position:absolute; left:100%; bottom:0; margin-left:10px; width:250px; padding:14px;
display:none; z-index:2147483647; border-radius:var(--iux-radius);
max-height:min(420px, 80vh); overflow-y:auto;
}
#__iux_participant_pop.open { display:block; }
#__iux_participant_pop h4 { margin:0 0 8px; font-size:12px; color:var(--iux-text); }
.iux-subject-list { display:flex; flex-direction:column; gap:2px; margin-bottom:2px; }
.iux-subject-row {
display:flex; align-items:center; gap:9px; padding:7px 8px; border-radius:8px; cursor:pointer;
font-size:12px; color:var(--iux-text-dim); transition:background .12s ease, color .12s ease;
}
.iux-subject-row:hover { background:var(--iux-surface-hi); }
.iux-subject-row.on { color:var(--iux-text); font-weight:600; }
.iux-subject-radio {
width:14px; height:14px; border-radius:50%; border:1.5px solid var(--iux-border);
flex-shrink:0; position:relative;
}
.iux-subject-row.on .iux-subject-radio { border-color:var(--iux-primary-light); }
.iux-subject-row.on .iux-subject-radio::after {
content:''; position:absolute; inset:2.5px; border-radius:50%; background:var(--iux-primary-light);
}
.iux-subject-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.iux-participant-label {
font-size:9.5px; text-transform:uppercase; letter-spacing:.06em; color:var(--iux-text-faint);
font-weight:700; margin:8px 2px 4px;
}
.iux-participant-empty { font-size:11px; color:var(--iux-text-faint); padding:6px 8px; }
.iux-participant-back {
display:flex; align-items:center; gap:6px; background:transparent; border:none;
color:var(--iux-text-faint); font-size:11.5px; cursor:pointer; padding:0; margin-bottom:12px;
}
.iux-participant-back:hover { color:var(--iux-text); }
.iux-participant-error {
display:none; font-size:11px; color:var(--iux-danger); background:rgba(251,113,133,0.1);
border:1px solid rgba(251,113,133,0.3); border-radius:8px; padding:7px 9px; margin-bottom:10px;
}
.iux-participant-error.on { display:block; }
.iux-field { display:flex; flex-direction:column; gap:4px; margin-bottom:10px; }
.iux-field label {
font-size:10px; color:var(--iux-text-faint); font-weight:600;
text-transform:uppercase; letter-spacing:.04em;
}
.iux-field input {
padding:8px 10px; font-size:12.5px; border-radius:8px; border:1.5px solid var(--iux-border);
background:var(--iux-surface); color:var(--iux-text); outline:none;
transition:border-color .15s var(--iux-ease), box-shadow .15s var(--iux-ease);
}
.iux-field input:focus { border-color:var(--iux-primary-light); box-shadow:var(--iux-shadow-glow); }
.iux-form-row { display:flex; gap:8px; margin-top:4px; }
.iux-form-row .iux-btn { flex:1 1 auto; justify-content:center; padding:8px; font-size:11.5px; }
.iux-form-row .iux-btn.primary { background:var(--iux-accent-grad); color:var(--iux-on-accent); border-color:transparent; }
#__insightux_update {
display:none; cursor:pointer; flex:0 0 auto; white-space:nowrap;
padding:6px 13px; border-radius:999px; font-weight:600; font-size:11px;
background:var(--iux-accent-grad); color:var(--iux-on-accent); align-items:center; gap:6px;
}
#__insightux_update:hover { transform:translateY(-1px); }
/* -- profile avatar + three-dot menu: right end of the toolbar -- */
#__iux_profile_btn {
width:32px; height:32px; padding:0; border-radius:50%; flex-shrink:0;
background:var(--iux-accent-grad); color:var(--iux-on-accent);
font:700 12.5px var(--iux-font); border-color:transparent;
}
.iux-toolbar-pop {
position:absolute; top:calc(100% + 8px); right:0; z-index:2147483647;
min-width:230px; padding:8px; border-radius:var(--iux-radius);
display:none; flex-direction:column; gap:2px;
}
.iux-toolbar-pop.open { display:flex; }
.iux-profile-head { display:flex; align-items:center; gap:10px; padding:8px 10px 10px; }
.iux-profile-avatar {
width:34px; height:34px; border-radius:50%; flex-shrink:0;
background:var(--iux-accent-grad); color:var(--iux-on-accent);
display:flex; align-items:center; justify-content:center; font:700 13px var(--iux-font);
}
.iux-profile-name { font-size:12.5px; font-weight:600; color:var(--iux-text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.iux-profile-email { font-size:11px; color:var(--iux-text-faint); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.iux-profile-active {
display:flex; align-items:center; gap:6px; padding:0 10px 8px;
font-size:10.5px; color:var(--iux-success); font-weight:600;
}
.iux-menu-sep { height:1px; background:var(--iux-border); margin:4px 6px; }
.iux-menu-item {
display:flex; align-items:center; gap:8px; width:100%; text-align:left;
padding:8px 10px; border-radius:8px; border:none; background:transparent;
color:var(--iux-text-dim); font-size:12px; font-family:var(--iux-font);
cursor:pointer; transition:background .12s ease, color .12s ease;
}
.iux-menu-item:hover { background:var(--iux-surface-hi); color:var(--iux-text); }
#__iux_info_modal {
position:fixed; inset:0; z-index:2147483647; display:none;
align-items:center; justify-content:center; background:rgba(0,0,0,0.35);
}
#__iux_info_modal.open { display:flex; }
#__iux_info_modal .box {
width:340px; max-width:88vw; padding:20px; border-radius:var(--iux-radius);
}
#__iux_info_modal h4 { margin:0 0 8px; font-size:14px; color:var(--iux-text); display:flex; align-items:center; gap:8px; }
#__iux_info_modal p { margin:0; font-size:12.5px; line-height:1.6; color:var(--iux-text-dim); }
#__iux_info_modal .iux-btn { margin-top:14px; width:100%; justify-content:center; }
/* -- session history: Profile > My Sessions / Three-dot > Persons -- */
#__iux_sessions_modal {
position:fixed; inset:0; z-index:2147483647; display:none;
align-items:center; justify-content:center; background:rgba(0,0,0,0.35);
}
#__iux_sessions_modal.open { display:flex; }
#__iux_sessions_modal .box {
width:440px; max-width:90vw; max-height:78vh; padding:18px;
border-radius:var(--iux-radius); display:flex; flex-direction:column; gap:12px;
}
.iux-sessions-head { display:flex; align-items:center; gap:8px; flex-shrink:0; }
.iux-sessions-head h4 { margin:0; font-size:13.5px; color:var(--iux-text); }
.iux-sessions-back {
width:26px; height:26px; border-radius:8px; border:1px solid var(--iux-border);
background:var(--iux-surface); color:var(--iux-text-dim); cursor:pointer;
display:flex; align-items:center; justify-content:center; flex-shrink:0; padding:0;
}
.iux-sessions-back:hover { background:var(--iux-surface-hi); color:var(--iux-text); }
#__iux_sessions_search_wrap {
display:none; align-items:center; gap:8px; padding:7px 11px; border-radius:9px;
border:1px solid var(--iux-border); background:var(--iux-surface); flex-shrink:0;
}
#__iux_sessions_search_wrap.on { display:flex; }
#__iux_sessions_search_wrap svg { color:var(--iux-text-faint); flex-shrink:0; }
#__iux_sessions_search_input {
flex:1 1 auto; min-width:0; border:none; outline:none; background:transparent;
font-size:12.5px; color:var(--iux-text); font-family:var(--iux-font);
}
#__iux_sessions_search_input::placeholder { color:var(--iux-text-faint); }
#__iux_sessions_body { overflow-y:auto; display:flex; flex-direction:column; gap:8px; min-height:60px; }
.iux-session-card, .iux-person-card {
display:flex; align-items:center; gap:12px; padding:12px 14px; border-radius:10px;
border:1px solid var(--iux-border); background:var(--iux-surface-hi);
cursor:pointer; transition:background .12s ease, transform .12s ease; flex-shrink:0;
}
.iux-session-card:hover, .iux-person-card:hover { background:var(--iux-surface); transform:translateY(-1px); }
.iux-session-card .info, .iux-person-card .info { flex:1 1 auto; min-width:0; }
.iux-session-card .when, .iux-person-card .when { font-size:12px; font-weight:600; color:var(--iux-text); }
.iux-session-card .meta, .iux-person-card .meta {
font-size:11px; color:var(--iux-text-faint); margin-top:3px;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.iux-session-card .go, .iux-person-card .go {
flex-shrink:0; font-size:11px; font-weight:600; color:var(--iux-primary-light);
white-space:nowrap; display:flex; align-items:center; gap:4px;
}
.iux-person-card .avatar {
width:32px; height:32px; border-radius:50%; flex-shrink:0;
background:var(--iux-accent-grad); color:var(--iux-on-accent);
display:flex; align-items:center; justify-content:center; font:700 13px var(--iux-font);
}
.iux-sessions-empty { font-size:12px; color:var(--iux-text-faint); text-align:center; padding:24px 0; }
.iux-session-label-row { display:flex; align-items:center; gap:6px; }
.iux-session-edit-btn {
flex-shrink:0; width:20px; height:20px; border-radius:6px; display:flex; align-items:center;
justify-content:center; color:var(--iux-text-faint); opacity:0; transition:opacity .12s ease, color .12s ease;
}
.iux-session-card:hover .iux-session-edit-btn, .iux-person-card:hover .iux-session-edit-btn { opacity:1; }
.iux-session-edit-btn:hover { color:var(--iux-primary-light); background:var(--iux-surface); }
.iux-session-name-edit { display:flex; align-items:center; gap:6px; width:100%; }
.iux-session-name-input {
flex:1 1 auto; min-width:0; padding:5px 9px; font-size:12px; border-radius:7px;
border:1.5px solid var(--iux-primary-light); background:var(--iux-surface); color:var(--iux-text); outline:none;
}
.iux-session-name-edit .iux-btn { flex-shrink:0; width:22px; height:22px; padding:0; }
.iux-person-edit { display:flex; flex-direction:column; gap:6px; width:100%; }
.iux-person-edit .iux-session-name-input { width:100%; }
.iux-person-edit-actions { display:flex; align-items:center; gap:6px; justify-content:flex-end; }
.iux-person-edit-actions .iux-btn { flex-shrink:0; width:22px; height:22px; padding:0; }
.iux-session-thumb {
flex-shrink:0; width:52px; height:36px; border-radius:7px; object-fit:cover;
background:var(--iux-surface); border:1px solid var(--iux-border);
}
.iux-session-thumb-empty {
display:flex; align-items:center; justify-content:center; color:var(--iux-text-faint);
}
`;
document.head.appendChild(style);
const loadbar = document.createElement('div');
loadbar.id = '__iux_loadbar';
document.documentElement.appendChild(loadbar);
requestAnimationFrame(function(){
loadbar.style.opacity = '1'; loadbar.style.width = '70%';
setTimeout(function(){ loadbar.style.width = '100%'; }, 160);
setTimeout(function(){ loadbar.style.opacity = '0'; }, 550);
});
const toolbar = document.createElement('div');
toolbar.id = '__insightux_toolbar';
toolbar.className = 'iux-glass';
toolbar.innerHTML = `
<button type="button" class="iux-btn iux-navbtn" id="__iux_back" title="Back">${iuxIcon('arrow-left',15)}</button>
<button type="button" class="iux-btn iux-navbtn" id="__iux_fwd" title="Forward">${iuxIcon('arrow-right',15)}</button>
<button type="button" class="iux-btn iux-navbtn" id="__iux_reload" title="Reload">${iuxIcon('refresh',15)}</button>
<button type="button" class="iux-btn iux-navbtn" id="__iux_home" title="Home">${iuxIcon('home',15)}</button>
<div id="__iux_addr_wrap">
<span class="lead-icon" id="__iux_security_icon"></span>
<form id="__iux_addr_form"><input id="__iux_addr" type="text" autocomplete="off"
placeholder="Search or enter address"></form>
</div>
<div id="__insightux_indicators" style="display:none;">
<span class="iux-pill" id="__iux_pill_eye"><span class="dot"></span>Eye Tracking</span>
<span class="iux-pill" id="__iux_pill_mouse"><span class="dot"></span>Mouse Tracking</span>
<span class="iux-pill" id="__iux_pill_time" style="display:none;">${iuxIcon('clock',11)}<span class="txt">00:00</span></span>
</div>
<span id="__insightux_update" title="Click to open the download page"></span>
<span id="__insightux_status" class="iux-pill" style="display:none;"><span class="txt">Press S to start &middot; H heatmap &middot; M panel &middot; X quit</span></span>
<button type="button" class="iux-btn" id="__iux_reveal_toggle" title="Show controls" style="width:32px;height:32px;display:none;">${iuxIcon('menu',15)}</button>
<button type="button" class="iux-btn" id="__iux_theme_toggle" title="Toggle theme" style="width:32px;height:32px;"></button>
<div style="position:relative;">
<button type="button" class="iux-btn" id="__iux_profile_btn" title="Profile"></button>
<div class="iux-toolbar-pop iux-glass" id="__iux_profile_pop">
<div class="iux-profile-head">
<div class="iux-profile-avatar" id="__iux_profile_avatar"></div>
<div style="min-width:0;">
<div class="iux-profile-name" id="__iux_profile_name"></div>
<div class="iux-profile-email" id="__iux_profile_email"></div>
</div>
</div>
<div class="iux-profile-active">${iuxIcon('check',12)} Active profile</div>
<div class="iux-menu-sep"></div>
<button type="button" class="iux-menu-item" id="__iux_my_sessions_btn">${iuxIcon('clock',14)} My Sessions</button>
<div class="iux-menu-sep"></div>
<button type="button" class="iux-menu-item" id="__iux_switch_profile">${iuxIcon('users',14)} Switch profile</button>
<button type="button" class="iux-menu-item" id="__iux_logout">${iuxIcon('log-out',14)} Log out</button>
</div>
</div>
<div style="position:relative;">
<button type="button" class="iux-btn iux-navbtn" id="__iux_more_btn" title="More">${iuxIcon('more-vertical',15)}</button>
<div class="iux-toolbar-pop iux-glass" id="__iux_more_pop">
<button type="button" class="iux-menu-item" id="__iux_persons_btn">${iuxIcon('users',14)} Persons</button>
<div class="iux-menu-sep"></div>
<button type="button" class="iux-menu-item" id="__iux_more_help">${iuxIcon('info',14)} Help</button>
<button type="button" class="iux-menu-item" id="__iux_more_about">${iuxIcon('eye',14)} About InsightUX</button>
</div>
</div>
`;
document.documentElement.appendChild(toolbar);
const sidebar = document.createElement('div');
sidebar.id = '__insightux_sidebar';
sidebar.className = 'iux-glass';
sidebar.innerHTML = `
<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>
<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>
<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>
<div class="spacer"></div>
<div style="position:relative;">
<button type="button" class="iux-btn side-btn" id="__iux_participant_btn">${iuxIcon('users',18)}<span class="lbl">Participant</span><span class="tip">Participant</span></button>
<div id="__iux_participant_pop" class="iux-glass iux-fade-in">
<div id="__iux_participant_main">
<h4>Tracking Subject</h4>
<div class="iux-subject-list" id="__iux_subject_list"></div>
<div class="iux-menu-sep"></div>
<div class="iux-participant-label">Participants</div>
<div class="iux-subject-list" id="__iux_participant_list"></div>
<button type="button" class="iux-menu-item" id="__iux_add_participant_btn">${iuxIcon('plus',14)} Add Participant</button>
</div>
<div id="__iux_participant_addform" style="display:none;">
<button type="button" class="iux-participant-back" id="__iux_add_participant_back">${iuxIcon('arrow-left',12)} Back</button>
<div class="iux-field"><label>Name</label><input id="__iux_add_participant_name" type="text" placeholder="Participant name"></div>
<div class="iux-field"><label>Notes (optional)</label><input id="__iux_add_participant_notes" type="text" placeholder="Optional notes"></div>
<div class="iux-participant-error" id="__iux_add_participant_error"></div>
<div class="iux-form-row">
<button type="button" class="iux-btn" id="__iux_add_participant_cancel">Cancel</button>
<button type="button" class="iux-btn primary" id="__iux_add_participant_submit">Add Participant</button>
</div>
</div>
</div>
</div>
<div style="position:relative;">
<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>
<div id="__iux_settings_pop" class="iux-glass iux-fade-in">
<h4>Shortcuts</h4>
<div class="kbd-row"><span>Start / Stop tracking</span><b>S / E</b></div>
<div class="kbd-row"><span>Toggle heatmap</span><b>H</b></div>
<div class="kbd-row"><span>Mouse panel</span><b>M</b></div>
<div class="kbd-row"><span>Quit</span><b>X</b></div>
<div class="ver">InsightUX v__INSIGHTUX_VERSION__</div>
</div>
</div>
<button type="button" class="iux-btn" id="__iux_collapse_btn" title="Collapse sidebar">${iuxIcon('chevron-left',14)}</button>
<div class="hints">S/E &middot; H &middot; M &middot; X</div>
`;
document.documentElement.appendChild(sidebar);
setInterval(function(){
if (!document.documentElement.contains(toolbar)) document.documentElement.appendChild(toolbar);
if (!document.documentElement.contains(sidebar)) document.documentElement.appendChild(sidebar);
}, 1000);
// -- theme toggle --
const themeBtn = document.getElementById('__iux_theme_toggle');
function paintThemeBtn(){
themeBtn.innerHTML = iuxIcon(window.insightuxGetTheme() === 'light' ? 'moon' : 'sun', 15);
}
paintThemeBtn();
themeBtn.addEventListener('click', function(){ window.insightuxToggleTheme(); paintThemeBtn(); });
// -- sidebar collapse (persisted like theme) --
const collapseBtn = document.getElementById('__iux_collapse_btn');
function applyCollapsed(collapsed){
sidebar.classList.toggle('collapsed', collapsed);
collapseBtn.innerHTML = iuxIcon(collapsed ? 'chevron-right' : 'chevron-left', 14);
}
applyCollapsed(window.insightuxGetPrefs().sidebarCollapsed === true);
collapseBtn.addEventListener('click', function(){
const collapsed = !sidebar.classList.contains('collapsed');
applyCollapsed(collapsed);
window.insightuxSetPref('sidebarCollapsed', collapsed);
});
// -- tracking-mode compact chrome: sidebar + toolbar shrink automatically
// once a session starts, so the tracked page gets maximum space, without
// ever removing the controls — __iux_reveal_toggle (only shown while
// tracking) brings the full toolbar/sidebar back temporarily. --
const revealToggle = document.getElementById('__iux_reveal_toggle');
let isTrackingNow = false;
let controlsRevealed = false;
function applyTrackingChrome(){
const compact = isTrackingNow && !controlsRevealed;
toolbar.classList.toggle('tracking-compact', compact);
document.body.classList.toggle('iux-tracking-compact', compact);
if (isTrackingNow) {
sidebar.classList.toggle('collapsed', !controlsRevealed);
} else {
applyCollapsed(window.insightuxGetPrefs().sidebarCollapsed === true);
}
revealToggle.innerHTML = iuxIcon(controlsRevealed ? 'x' : 'menu', 15);
revealToggle.title = controlsRevealed ? 'Hide controls' : 'Show controls';
}
revealToggle.addEventListener('click', function(){
controlsRevealed = !controlsRevealed;
applyTrackingChrome();
});
// -- settings popover --
const settingsBtn = document.getElementById('__iux_settings_btn');
const settingsPop = document.getElementById('__iux_settings_pop');
settingsBtn.addEventListener('click', function(e){
e.stopPropagation();
participantPop.classList.remove('open');
settingsPop.classList.toggle('open');
});
document.addEventListener('click', function(){
settingsPop.classList.remove('open');
participantPop.classList.remove('open');
});
// -- participant panel ------------------------------------------------
// Tracking subject (Myself vs. a participant) and the participant list
// itself both live only in Python (participants.py) — this panel is a
// thin view over list_participants()/add_participant()/
// set_tracking_subject(), the same call-Python-then-repaint pattern the
// profile popover already uses.
const participantBtn = document.getElementById('__iux_participant_btn');
const participantPop = document.getElementById('__iux_participant_pop');
const participantMain = document.getElementById('__iux_participant_main');
const participantAddForm = document.getElementById('__iux_participant_addform');
let PARTICIPANTS_CACHE = [];
let ACTIVE_SUBJECT_ID = ''; // '' = Myself
participantPop.addEventListener('click', function(e){ e.stopPropagation(); });
participantBtn.addEventListener('click', function(e){
e.stopPropagation();
settingsPop.classList.remove('open');
const wasOpen = participantPop.classList.contains('open');
participantPop.classList.remove('open');
if (!wasOpen) {
participantPop.classList.add('open');
showParticipantMain();
refreshParticipantList();
}
});
function showParticipantMain(){
participantAddForm.style.display = 'none';
participantMain.style.display = 'block';
}
function refreshParticipantList(){
if (!window.pywebview) return;
window.pywebview.api.list_participants(__IUX_TOK__).then(function(list){
PARTICIPANTS_CACHE = list || [];
renderSubjectList();
});
}
function renderSubjectList(){
const wrap = document.getElementById('__iux_subject_list');
wrap.innerHTML = '<div class="iux-subject-row' + (ACTIVE_SUBJECT_ID === '' ? ' on' : '') + '" data-id="">' +
'<span class="iux-subject-radio"></span><span class="iux-subject-name">Myself</span></div>';
const plist = document.getElementById('__iux_participant_list');
if (!PARTICIPANTS_CACHE.length) {
plist.innerHTML = '<div class="iux-participant-empty">No participants yet.</div>';
} else {
plist.innerHTML = PARTICIPANTS_CACHE.map(function(p){
return '<div class="iux-subject-row' + (ACTIVE_SUBJECT_ID === p.id ? ' on' : '') + '" data-id="' + p.id + '">' +
'<span class="iux-subject-radio"></span><span class="iux-subject-name">' + escapeHtml(p.name) + '</span></div>';
}).join('');
}
Array.from(wrap.children).concat(Array.from(plist.children)).forEach(function(row){
if (!row.dataset || row.dataset.id === undefined) return;
row.addEventListener('click', function(){
const id = row.getAttribute('data-id');
if (id === ACTIVE_SUBJECT_ID || !window.pywebview) return;
window.pywebview.api.set_tracking_subject(id || null, __IUX_TOK__).then(function(ok){
if (ok) { ACTIVE_SUBJECT_ID = id; renderSubjectList(); }
});
});
});
}
window.insightuxSetSubject = function(subject){
ACTIVE_SUBJECT_ID = (subject && subject.id) || '';
if (participantPop.classList.contains('open')) renderSubjectList();
};
document.getElementById('__iux_add_participant_btn').addEventListener('click', function(e){
e.stopPropagation();
participantMain.style.display = 'none';
participantAddForm.style.display = 'block';
document.getElementById('__iux_add_participant_error').classList.remove('on');
document.getElementById('__iux_add_participant_name').value = '';
document.getElementById('__iux_add_participant_notes').value = '';
document.getElementById('__iux_add_participant_name').focus();
});
document.getElementById('__iux_add_participant_back').addEventListener('click', function(e){
e.stopPropagation();
showParticipantMain();
});
document.getElementById('__iux_add_participant_cancel').addEventListener('click', function(e){
e.stopPropagation();
showParticipantMain();
});
document.getElementById('__iux_add_participant_submit').addEventListener('click', function(e){
e.stopPropagation();
if (!window.pywebview) return;
const name = document.getElementById('__iux_add_participant_name').value.trim();
const notes = document.getElementById('__iux_add_participant_notes').value.trim();
const errEl = document.getElementById('__iux_add_participant_error');
errEl.classList.remove('on');
if (!name) { errEl.textContent = 'Name is required.'; errEl.classList.add('on'); return; }
const btn = e.currentTarget;
btn.disabled = true;
window.pywebview.api.add_participant(name, notes, __IUX_TOK__).then(function(res){
btn.disabled = false;
if (res && res.ok) {
showParticipantMain();
refreshParticipantList();
} else {
errEl.textContent = (res && res.error) || 'Could not add participant.';
errEl.classList.add('on');
}
});
});
// -- profile popover + three-dot menu --------------------------------
// Same call-a-Python-method-then-let-it-navigate pattern as every other
// sidebar action (__iux_calibrate/__iux_validate/__iux_session above) —
// no new plumbing, just two more buttons feeding the existing callApi().
const profileBtn = document.getElementById('__iux_profile_btn');
const profilePop = document.getElementById('__iux_profile_pop');
const moreBtn = document.getElementById('__iux_more_btn');
const morePop = document.getElementById('__iux_more_pop');
function closeAllPops(){
settingsPop.classList.remove('open');
profilePop.classList.remove('open');
morePop.classList.remove('open');
}
profileBtn.addEventListener('click', function(e){
e.stopPropagation();
const wasOpen = profilePop.classList.contains('open');
closeAllPops();
if (!wasOpen) profilePop.classList.add('open');
});
moreBtn.addEventListener('click', function(e){
e.stopPropagation();
const wasOpen = morePop.classList.contains('open');
closeAllPops();
if (!wasOpen) morePop.classList.add('open');
});
document.addEventListener('click', closeAllPops);
window.insightuxSetProfile = function(profile){
if (!profile || !profile.name) return;
profileBtn.textContent = profile.avatar || profile.name.charAt(0).toUpperCase();
document.getElementById('__iux_profile_avatar').textContent = profile.avatar || profile.name.charAt(0).toUpperCase();
document.getElementById('__iux_profile_name').textContent = profile.name;
document.getElementById('__iux_profile_email').textContent = profile.email || '';
};
document.getElementById('__iux_switch_profile').addEventListener('click', function(){
closeAllPops();
callApi('switch_profile_screen');
});
document.getElementById('__iux_logout').addEventListener('click', function(){
closeAllPops();
callApi('logout');
});
document.getElementById('__iux_my_sessions_btn').addEventListener('click', function(){
closeAllPops();
openMySessions();
});
// -- Persons (three-dot menu) --
document.getElementById('__iux_persons_btn').addEventListener('click', function(){
closeAllPops();
openPersonsList();
});
// -- session history modal: Profile > My Sessions / Three-dot > Persons
// > a specific participant's history. One shared overlay, reused for
// all three views (renderSessionList for a flat session list,
// renderPersonList for the participant picker) so there's a single
// place that knows how to render a session card / a "View Report" link.
const sessionsModal = document.createElement('div');
sessionsModal.id = '__iux_sessions_modal';
sessionsModal.innerHTML = `
<div class="box iux-glass iux-fade-in">
<div class="iux-sessions-head">
<button type="button" class="iux-sessions-back" id="__iux_sessions_back" style="display:none;">${iuxIcon('arrow-left',13)}</button>
<h4 id="__iux_sessions_title"></h4>
<button type="button" class="iux-btn" id="__iux_sessions_close" style="width:28px;height:28px;padding:0;margin-left:auto;">${iuxIcon('x',13)}</button>
</div>
<div id="__iux_sessions_search_wrap">
${iuxIcon('search',13)}
<input type="text" id="__iux_sessions_search_input" placeholder="Search sessions by name or site" maxlength="100">
</div>
<div id="__iux_sessions_body"></div>
</div>
`;
document.documentElement.appendChild(sessionsModal);
document.getElementById('__iux_sessions_close').addEventListener('click', function(){ sessionsModal.classList.remove('open'); });
sessionsModal.addEventListener('click', function(e){ if (e.target === sessionsModal) sessionsModal.classList.remove('open'); });
// Client-side only -- the full list for whatever session view is
// currently open is already in memory (fetched once by load() in
// openMySessions()/openPersonDetails()), so filtering as the user types
// needs no round trip to Python. Re-applied after every reload() too
// (rename/delete), so the filter text survives an in-place refresh.
let __iuxAllSessions = [];
let __iuxSessionsCtx = null;
const sessionsSearchInput = document.getElementById('__iux_sessions_search_input');
sessionsSearchInput.addEventListener('click', function(e){ e.stopPropagation(); });
sessionsSearchInput.addEventListener('keydown', function(e){ e.stopPropagation(); });
sessionsSearchInput.addEventListener('input', applySessionFilter);
function applySessionFilter(){
const q = sessionsSearchInput.value.trim().toLowerCase();
const filtered = !q ? __iuxAllSessions : __iuxAllSessions.filter(function(s){
return (s.customName && s.customName.toLowerCase().includes(q)) ||
(s.label && s.label.toLowerCase().includes(q)) ||
(s.domain && s.domain.toLowerCase().includes(q));
});
const emptyMessage = (q && __iuxAllSessions.length)
? 'No sessions match “' + escapeHtml(sessionsSearchInput.value.trim()) + '”.'
: undefined;
renderSessionList(filtered, __iuxSessionsCtx, emptyMessage);
}
function openSessionsModal(title, onBack, searchable){
document.getElementById('__iux_sessions_title').textContent = title;
const backBtn = document.getElementById('__iux_sessions_back');
backBtn.style.display = onBack ? 'flex' : 'none';
backBtn.onclick = onBack || null;
document.getElementById('__iux_sessions_body').innerHTML = '<div class="iux-sessions-empty">Loading…</div>';
sessionsSearchInput.value = '';
document.getElementById('__iux_sessions_search_wrap').classList.toggle('on', !!searchable);
__iuxAllSessions = [];
__iuxSessionsCtx = null;
sessionsModal.classList.add('open');
}
// Session rename text is user-typed and, unlike the auto timestamp label,
// now persists to disk and is re-rendered on every future visit to this
// list — escape it before it goes into innerHTML so a rename can't smuggle
// in markup that runs in this window's own script context.
function escapeHtml(str){
return String(str).replace(/[&<>"']/g, function(c){
return { '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c];
});
}
function sessionCardHtml(s, i){
const stats = [];
if (s.domain) stats.push(escapeHtml(s.domain)); // domain comes from the tracked page's own URL, not typed by anyone in this app
if (s.duration != null) stats.push(s.duration + 's');
if (s.samples != null) stats.push(s.samples + ' gaze samples');
const displayName = escapeHtml(s.customName || s.label);
const metaLine = (s.customName ? [escapeHtml(s.label)].concat(stats) : stats).join(' &middot; ');
// s.thumbnail is a data: URI built server-side from base64-encoded
// image bytes (see _session_thumbnail_data_uri()) -- never user text,
// safe to drop straight into the src attribute unescaped.
const thumbHtml = s.thumbnail
? '<img class="iux-session-thumb" src="' + s.thumbnail + '" alt="">'
: '<span class="iux-session-thumb iux-session-thumb-empty">' + iuxIcon('image', 14) + '</span>';
return '<div class="iux-session-card"' + (s.reportUrl ? '' : ' style="cursor:default;"') + '>' +
thumbHtml +
'<div class="info">' +
'<div class="iux-session-label-row" id="__iux_sess_row' + i + '">' +
'<span class="when" id="__iux_sess_label' + i + '">' + displayName + '</span>' +
'<button type="button" class="iux-session-edit-btn" id="__iux_sess_edit' + i + '" title="Rename session">' + iuxIcon('type', 11) + '</button>' +
'<button type="button" class="iux-session-edit-btn" id="__iux_sess_export' + i + '" title="Export session (JSON + CSV)">' + iuxIcon('download', 11) + '</button>' +
'<button type="button" class="iux-session-edit-btn" id="__iux_sess_delete' + i + '" title="Delete session">' + iuxIcon('trash', 11) + '</button>' +
'</div>' +
(metaLine ? '<div class="meta">' + metaLine + '</div>' : '') +
'</div>' +
(s.reportUrl ? '<span class="go">View Report ' + iuxIcon('arrow-right', 12) + '</span>' : '') +
'</div>';
}
// Reused for both My Sessions and a specific participant's history — the
// `ctx` (openReport/rename/remove/reload) is what differs between the
// two, so this one function knows how to render a session card, open its
// report natively (avoids the http(s)->file:// script-navigation block —
// see Api._open_session_report()'s own comment), rename it in place, and
// delete it.
function renderSessionList(sessions, ctx, emptyMessage){
const body = document.getElementById('__iux_sessions_body');
if (!sessions.length) {
body.innerHTML = '<div class="iux-sessions-empty">' + (emptyMessage || 'No sessions yet.') + '</div>';
return;
}
body.innerHTML = sessions.map(function(s, i){ return sessionCardHtml(s, i); }).join('');
Array.from(body.children).forEach(function(card, i){
const s = sessions[i];
if (s.reportUrl) card.addEventListener('click', function(){ ctx.openReport(s.id); });
const editBtn = document.getElementById('__iux_sess_edit' + i);
if (editBtn) {
editBtn.addEventListener('click', function(e){ e.stopPropagation(); startEditingSessionName(sessions, i, ctx); });
}
const exportBtn = document.getElementById('__iux_sess_export' + i);
if (exportBtn) {
exportBtn.addEventListener('click', function(e){
e.stopPropagation();
exportBtn.style.pointerEvents = 'none';
ctx.export(s.id).then(function(ok){
exportBtn.style.pointerEvents = '';
if (ok) window.insightuxSetStatus && window.insightuxSetStatus('Session exported.');
});
});
}
const delBtn = document.getElementById('__iux_sess_delete' + i);
if (delBtn) {
delBtn.addEventListener('click', function(e){
e.stopPropagation();
if (!confirm('Delete this session and its report? This cannot be undone.')) return;
ctx.remove(s.id).then(function(ok){ if (ok) ctx.reload(); });
});
}
});
}
function startEditingSessionName(sessions, i, ctx){
const s = sessions[i];
const row = document.getElementById('__iux_sess_row' + i);
row.innerHTML =
'<div class="iux-session-name-edit">' +
'<input type="text" class="iux-session-name-input" id="__iux_sess_name_input' + i + '" value="' +
escapeHtml(s.customName || '') + '" placeholder="' + escapeHtml(s.label) + '" maxlength="80">' +
'<button type="button" class="iux-btn" id="__iux_sess_name_save' + i + '" title="Save">' + iuxIcon('check', 12) + '</button>' +
'<button type="button" class="iux-btn" id="__iux_sess_name_cancel' + i + '" title="Cancel">' + iuxIcon('x', 12) + '</button>' +
'</div>';
const input = document.getElementById('__iux_sess_name_input' + i);
input.addEventListener('click', function(e){ e.stopPropagation(); });
input.focus();
input.select();
function commit(){ ctx.rename(s.id, input.value).then(function(){ ctx.reload(); }); }
function cancel(){ ctx.reload(); }
document.getElementById('__iux_sess_name_save' + i).addEventListener('click', function(e){ e.stopPropagation(); commit(); });
document.getElementById('__iux_sess_name_cancel' + i).addEventListener('click', function(e){ e.stopPropagation(); cancel(); });
input.addEventListener('keydown', function(e){
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
});
}
function personCardHtml(p, i){
const last = p.last_session_at
? new Date(p.last_session_at).toLocaleDateString(undefined, {month:'short', day:'numeric', year:'numeric'})
: 'never';
const count = p.session_count || 0;
return '<div class="iux-person-card" data-id="' + p.id + '">' +
'<span class="avatar">' + (p.name ? escapeHtml(p.name.charAt(0).toUpperCase()) : '?') + '</span>' +
'<div class="info">' +
'<div class="iux-session-label-row" id="__iux_person_row' + i + '">' +
'<span class="when" id="__iux_person_label' + i + '">' + escapeHtml(p.name) + '</span>' +
'<button type="button" class="iux-session-edit-btn" id="__iux_person_edit' + i + '" title="Edit participant">' + iuxIcon('type', 11) + '</button>' +
'<button type="button" class="iux-session-edit-btn" id="__iux_person_delete' + i + '" title="Delete participant">' + iuxIcon('trash', 11) + '</button>' +
'</div>' +
'<div class="meta">' + count + ' tracking session' + (count === 1 ? '' : 's') + ' &middot; Last tracked: ' + last + '</div>' +
'</div>' +
'<span class="go">' + iuxIcon('arrow-right', 12) + '</span>' +
'</div>';
}
function renderPersonList(list){
const body = document.getElementById('__iux_sessions_body');
if (!list.length) {
body.innerHTML = '<div class="iux-sessions-empty">No participants yet — add one from the Participant panel.</div>';
return;
}
body.innerHTML = list.map(function(p, i){ return personCardHtml(p, i); }).join('');
Array.from(body.children).forEach(function(card, i){
const p = list[i];
card.addEventListener('click', function(){ openPersonDetails(p); });
const editBtn = document.getElementById('__iux_person_edit' + i);
if (editBtn) {
editBtn.addEventListener('click', function(e){ e.stopPropagation(); startEditingPerson(list, i); });
}
const delBtn = document.getElementById('__iux_person_delete' + i);
if (delBtn) {
delBtn.addEventListener('click', function(e){
e.stopPropagation();
if (!confirm('Delete ' + p.name + ' and all ' + (p.session_count || 0) + ' of their sessions? This cannot be undone.')) return;
window.pywebview.api.delete_participant(p.id, __IUX_TOK__).then(function(res){
if (!(res && res.ok) && res && res.error) window.insightuxSetStatus && window.insightuxSetStatus(res.error);
openPersonsList();
});
});
}
});
}
function startEditingPerson(list, i){
const p = list[i];
const row = document.getElementById('__iux_person_row' + i);
row.className = 'iux-person-edit';
row.innerHTML =
'<input type="text" class="iux-session-name-input" id="__iux_person_name_input' + i + '" value="' +
escapeHtml(p.name) + '" placeholder="Name" maxlength="60">' +
'<input type="text" class="iux-session-name-input" id="__iux_person_notes_input' + i + '" value="' +
escapeHtml(p.notes || '') + '" placeholder="Notes (optional)" maxlength="200">' +
'<div class="iux-person-edit-actions">' +
'<button type="button" class="iux-btn" id="__iux_person_save' + i + '" title="Save">' + iuxIcon('check', 12) + '</button>' +
'<button type="button" class="iux-btn" id="__iux_person_cancel' + i + '" title="Cancel">' + iuxIcon('x', 12) + '</button>' +
'</div>';
const nameInput = document.getElementById('__iux_person_name_input' + i);
const notesInput = document.getElementById('__iux_person_notes_input' + i);
[nameInput, notesInput].forEach(function(el){ el.addEventListener('click', function(e){ e.stopPropagation(); }); });
nameInput.focus();
nameInput.select();
function commit(){
const name = nameInput.value.trim();
if (!name) { nameInput.focus(); return; }
window.pywebview.api.update_participant(p.id, name, notesInput.value, __IUX_TOK__).then(function(res){
if (!(res && res.ok) && res && res.error) window.insightuxSetStatus && window.insightuxSetStatus(res.error);
openPersonsList();
});
}
function cancel(){ openPersonsList(); }
document.getElementById('__iux_person_save' + i).addEventListener('click', function(e){ e.stopPropagation(); commit(); });
document.getElementById('__iux_person_cancel' + i).addEventListener('click', function(e){ e.stopPropagation(); cancel(); });
[nameInput, notesInput].forEach(function(el){
el.addEventListener('keydown', function(e){
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
});
});
}
function openPersonDetails(person){
openSessionsModal(person.name, function(){ openPersonsList(); }, true);
if (!window.pywebview) return;
const ctx = {
openReport: function(id){ window.pywebview.api.open_participant_session_report(person.id, id, __IUX_TOK__); },
rename: function(id, name){ return window.pywebview.api.rename_participant_session(person.id, id, name, __IUX_TOK__); },
remove: function(id){ return window.pywebview.api.delete_participant_session(person.id, id, __IUX_TOK__); },
export: function(id){ return window.pywebview.api.export_participant_session(person.id, id, __IUX_TOK__); },
reload: function(){ load(); }
};
function load(){
window.pywebview.api.list_participant_sessions(person.id, __IUX_TOK__).then(function(sessions){
__iuxAllSessions = sessions || [];
__iuxSessionsCtx = ctx;
applySessionFilter();
});
}
load();
}
function openPersonsList(){
openSessionsModal('Persons', null, false);
if (!window.pywebview) return;
window.pywebview.api.list_participants(__IUX_TOK__).then(function(list){
renderPersonList(list || []);
});
}
function openMySessions(){
openSessionsModal('My Sessions', null, true);
if (!window.pywebview) return;
const ctx = {
openReport: function(id){ window.pywebview.api.open_my_session_report(id, __IUX_TOK__); },
rename: function(id, name){ return window.pywebview.api.rename_my_session(id, name, __IUX_TOK__); },
remove: function(id){ return window.pywebview.api.delete_my_session(id, __IUX_TOK__); },
export: function(id){ return window.pywebview.api.export_my_session(id, __IUX_TOK__); },
reload: function(){ load(); }
};
function load(){
window.pywebview.api.list_my_sessions(__IUX_TOK__).then(function(sessions){
__iuxAllSessions = sessions || [];
__iuxSessionsCtx = ctx;
applySessionFilter();
});
}
load();
}
const infoModal = document.createElement('div');
infoModal.id = '__iux_info_modal';
infoModal.innerHTML = `
<div class="box iux-glass iux-fade-in">
<h4 id="__iux_info_title"></h4>
<p id="__iux_info_body"></p>
<button type="button" class="iux-btn" id="__iux_info_close">Close</button>
</div>
`;
document.documentElement.appendChild(infoModal);
document.getElementById('__iux_info_close').addEventListener('click', function(){ infoModal.classList.remove('open'); });
infoModal.addEventListener('click', function(e){ if (e.target === infoModal) infoModal.classList.remove('open'); });
function showInfo(icon, title, body){
document.getElementById('__iux_info_title').innerHTML = iuxIcon(icon, 16) + ' ' + title;
document.getElementById('__iux_info_body').textContent = body;
infoModal.classList.add('open');
}
document.getElementById('__iux_more_help').addEventListener('click', function(){
closeAllPops();
showInfo('info', 'Help', 'Press S to start tracking on any website, E to stop. H toggles the attention heatmap, M opens the mouse panel, and X quits InsightUX. Calibrate and Validate live in the left sidebar.');
});
document.getElementById('__iux_more_about').addEventListener('click', function(){
closeAllPops();
showInfo('eye', 'About InsightUX', 'InsightUX v__INSIGHTUX_VERSION__ — AI-powered eye and mouse tracking research browser. Each profile is private: sessions, calibration, and reports are stored separately per profile and never shared with other profiles on this device. Switching profiles or logging out clears the active profile from memory — the next person has to sign in with their own password before any of that data is reachable again.');
});
// -- security icon (padlock for https, globe otherwise) --
document.getElementById('__iux_security_icon').innerHTML =
iuxIcon(location.protocol === 'https:' ? 'lock' : 'globe', 14);
// -- status text / pills, driven by the same Python calls as before --
const statusEl = () => document.querySelector('#__insightux_status .txt');
window.insightuxSetStatus = function(text){
const el = statusEl();
if (el) el.textContent = text;
// Only ever a real event (calibration/validation launch+finish, tracking
// start, "wrapping up", or an error) reaches this function — the default
// idle hint baked into the HTML never does — so showing the pill here
// and nowhere else keeps it hidden during plain idle browsing without
// touching any of the calls themselves.
const wrap = document.getElementById('__insightux_status');
if (wrap) wrap.style.display = text ? 'inline-flex' : 'none';
};
let sessionStartedAt = null, timerInterval = null;
function tickTimer(){
const pill = document.getElementById('__iux_pill_time');
if (!sessionStartedAt) { pill.style.display = 'none'; return; }
const diff = Math.floor((Date.now() - sessionStartedAt) / 1000);
const m = String(Math.floor(diff / 60)).padStart(2, '0');
const s = String(diff % 60).padStart(2, '0');
pill.querySelector('.txt').textContent = m + ':' + s;
pill.style.display = 'inline-flex';
}
// startedAtMs is the session's real start time (Api.session_started_at,
// fixed once at start_tracking()) — this same function runs again on
// every page navigation while tracking is active (on_page_loaded()
// re-injects the whole toolbar into a fresh JS context), so it must NOT
// just stamp Date.now() every time or the visible timer restarts on
// every navigation instead of counting the whole session.
window.insightuxSetSessionUI = function(isTracking, startedAtMs){
const btn = document.getElementById('__iux_session');
if (btn) {
btn.innerHTML = isTracking
? iuxIcon('stop',18) + '<span class="lbl">Stop<br>Session</span><span class="tip">Stop Session</span>'
: iuxIcon('play',18) + '<span class="lbl">Start<br>Session</span><span class="tip">Start Session</span>';
btn.classList.toggle('active', isTracking);
}
document.getElementById('__iux_pill_eye').classList.toggle('on', isTracking);
document.getElementById('__iux_pill_mouse').classList.toggle('on', isTracking);
const indicators = document.getElementById('__insightux_indicators');
if (indicators) indicators.style.display = isTracking ? 'flex' : 'none';
isTrackingNow = isTracking;
controlsRevealed = false;
applyTrackingChrome();
if (isTracking) {
sessionStartedAt = startedAtMs || Date.now();
if (timerInterval) clearInterval(timerInterval);
timerInterval = setInterval(tickTimer, 1000);
tickTimer();
} else {
sessionStartedAt = null;
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
tickTimer();
}
};
window.insightuxShowUpdate = function(version, url){
const el = document.getElementById('__insightux_update');
if (!el) return;
el.innerHTML = iuxIcon('download', 12) + '<span>Update available (v' + version + ')</span>';
el.style.display = 'inline-flex';
el.onclick = function(){
if (window.pywebview && window.pywebview.api && window.pywebview.api.open_update_page) {
window.pywebview.api.open_update_page(url, __IUX_TOK__);
}
};
};
// -- address bar / navigation (pure DOM history/location, no Python round-trip) --
const addrInput = document.getElementById('__iux_addr');
addrInput.value = isLanding ? '' : location.href;
document.getElementById('__iux_addr_form').addEventListener('submit', function(e){
e.preventDefault();
const raw = addrInput.value.trim();
if (!raw) return;
const looksLikeUrl = /^https?:\/\//i.test(raw) ||
(/^[\w-]+(\.[\w-]+)+([/?#].*)?$/i.test(raw) && !raw.includes(' '));
if (looksLikeUrl) {
location.href = raw.startsWith('http') ? raw : ('https://' + raw);
} else {
location.href = 'https://www.google.com/search?q=' + encodeURIComponent(raw);
}
});
document.getElementById('__iux_back').addEventListener('click', function(){ history.back(); });
document.getElementById('__iux_fwd').addEventListener('click', function(){ history.forward(); });
document.getElementById('__iux_reload').addEventListener('click', function(){ location.reload(); });
// -- sidebar actions (Python decides validity — camera/session conflicts,
// wrong page — and reports back through the status text) --
function callApi(method){
if (window.pywebview && window.pywebview.api && window.pywebview.api[method]) {
window.pywebview.api[method](__IUX_TOK__);
}
}
// A page loaded over http(s) can't script-navigate itself to a file://
// URL — the browser silently blocks it, no exception, no navigation —
// so this has to go through the native window.load_url() call in
// Api.go_home() instead of a plain location.href assignment.
document.getElementById('__iux_home').addEventListener('click', function(){ callApi('go_home'); });
document.getElementById('__iux_calibrate').addEventListener('click', function(){ callApi('start_calibration'); });
document.getElementById('__iux_validate').addEventListener('click', function(){ callApi('run_validation'); });
document.getElementById('__iux_session').addEventListener('click', function(){ callApi('toggle_session'); });
// -- adaptive visibility while browsing a real website: hide on scroll
// down, reveal on scroll up or near the top. Landing page keeps chrome
// permanently visible (no listener attached there); report page never
// reaches this code at all (whole block is skipped when isReport).
if (isTrackable) {
let lastScrollY = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
const REVEAL_ZONE = 80, HIDE_DELTA = 24;
window.addEventListener('scroll', function(){
const y = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
const goingDown = y > lastScrollY + HIDE_DELTA;
const goingUp = y < lastScrollY - HIDE_DELTA;
if (y < REVEAL_ZONE) {
toolbar.classList.remove('chrome-hidden');
sidebar.classList.remove('chrome-hidden');
} else if (goingDown) {
toolbar.classList.add('chrome-hidden');
sidebar.classList.add('chrome-hidden');
lastScrollY = y;
} else if (goingUp) {
toolbar.classList.remove('chrome-hidden');
sidebar.classList.remove('chrome-hidden');
lastScrollY = y;
}
}, { passive: true });
}
} // end if (!isReport && !isLogin)
function isTypingTarget(el){
if (!el) return false;
const tag = el.tagName ? el.tagName.toLowerCase() : '';
return tag === 'input' || tag === 'textarea' || tag === 'select' ||
el.isContentEditable === true;
}
document.addEventListener('keydown', function(e){
const typingBlocked = isTypingTarget(e.target) || isTypingTarget(document.activeElement);
const apiReady = !!(window.pywebview && window.pywebview.api);
if (e.key === 'x' || e.key === 'X') {
if (typingBlocked || !apiReady) return;
window.pywebview.api.quit_app(__IUX_TOK__);
return;
}
if (!isTrackable) return; // S/E do nothing on the landing page or a report page
if (e.key === 's' || e.key === 'S' || e.key === 'e' || e.key === 'E') {
console.log('[insightux] key=' + e.key, 'typingBlocked=' + typingBlocked, 'apiReady=' + apiReady, 'focusedEl=' + (document.activeElement && document.activeElement.tagName));
}
if (!apiReady) return;
if (typingBlocked) {
window.insightuxSetStatus("Click somewhere on the page (not a text field) before pressing S/E");
return;
}
if (e.key === 's' || e.key === 'S') {
window.pywebview.api.start_tracking(__IUX_TOK__);
} else if (e.key === 'e' || e.key === 'E') {
window.pywebview.api.stop_tracking(__IUX_TOK__);
}
}, true); // capture phase — fires before page scripts can intercept/stop the event
})();
"""
CHROME_JS = CHROME_JS.replace("__ICONS_JS__", theme.ICONS_JS)
CHROME_JS = CHROME_JS.replace("__THEME_JS__", theme.THEME_TOGGLE_JS)
CHROME_JS = CHROME_JS.replace("__THEME_CSS__", theme.THEME_CSS)
CHROME_JS = CHROME_JS.replace("__INSIGHTUX_VERSION__", VERSION)
CHROME_JS = CHROME_JS.replace("__API_TOKEN__", _API_TOKEN)
# JS: tracking overlay (gaze dot + AOI highlighting) — injected only once
# tracking actually starts. Ported directly from run_session.py's JS_SETUP.
TRACKING_JS = r"""
(function(){
if (window.__insightux) { return; }
const OWN_UI_IDS = new Set(['__insightux_toolbar', '__insightux_sidebar', '__insightux_pill', '__insightux_canvas', '__insightux_mouse_panel']);
const OWN_UI_SELECTOR = '#__insightux_toolbar, #__insightux_sidebar, #__insightux_pill, #__insightux_canvas, #__insightux_mouse_panel';
const state = { aois: [] };
const dwell = { pendLabel: null, pendSince: 0, activeLabel: null, emptySince: 0 };
const DWELL_MS = 420;
const RELEASE_MS = 1200;
let activeBox = null;
const target = { fx: 0.5, fy: 0.5 };
const dot = { x: null, y: null };
const DOT_LERP = 0.07;
const cv = document.createElement('canvas');
cv.id = '__insightux_canvas';
cv.style.cssText = 'position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483646;';
(document.body || document.documentElement).appendChild(cv);
const ctx = cv.getContext('2d');
function resize(){ cv.width = window.innerWidth; cv.height = window.innerHeight; }
resize();
window.addEventListener('resize', resize, {passive:true});
setInterval(function(){
if (!document.body.contains(cv)) document.body.appendChild(cv);
}, 1000);
// Momentarily hides InsightUX's own injected UI (toolbar, sidebar, this
// gaze/AOI canvas, the mouse HUD panel) so pyautogui's full-screen grab in
// maybe_capture_screenshot() captures only the tracked page underneath —
// called from Python right before/after each screenshot, never during
// normal tracking.
window.__insightuxSetCaptureMode = function(hidden){
const vis = hidden ? 'hidden' : '';
cv.style.visibility = vis;
['__insightux_toolbar', '__insightux_sidebar', '__insightux_mouse_panel'].forEach(function(id){
const el = document.getElementById(id);
if (el) el.style.visibility = vis;
});
};
function roundRectPath(c, x, y, w, h, r){
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
const MIN_W=40, MIN_H=24, MAX_AOIS=90, MAX_SCAN=2500;
const PAD_PX = 90;
const TALL_WRAPPER_H = 220;
const ALWAYS_SELECTOR = "nav, header, footer, img, video, iframe, h1, h2, h3, button, figure, [class*='hero'], [class*='banner'], [class*='card']";
const TEXT_CONTAINER_SELECTOR = "div, span, section, article, main, aside, p, li";
function hasOwnText(el){
for (const node of el.childNodes){
if (node.nodeType === 3 && node.textContent.trim().length > 2) return true;
}
return false;
}
function labelFor(el){
if (el.dataset && el.dataset.aoi) return el.dataset.aoi.slice(0,40);
const tag = el.tagName.toLowerCase();
if (tag==='nav') return 'navbar';
if (tag==='header') return 'header';
if (tag==='footer') return 'footer';
if (tag==='img'){
const alt=(el.getAttribute('alt')||'').trim();
if (alt) return 'img: '+alt.slice(0,30);
const src=el.getAttribute('src')||'';
const name=src.split('/').pop().split('?')[0];
return 'img: '+(name||'image').slice(0,30);
}
if (tag==='video') return 'video';
if (tag==='iframe') return 'embed';
if (tag==='h1'||tag==='h2'){
const t=(el.innerText||'').trim().replace(/\s+/g,' ');
if (t) return tag+': '+t.slice(0,30);
}
const id=el.id?('#'+el.id):'';
let cls='';
if (el.className && typeof el.className==='string'){
const f=el.className.trim().split(/\s+/)[0];
if (f) cls='.'+f;
}
const txt=(el.innerText||'').trim().replace(/\s+/g,' ').slice(0,24);
const base=id||cls||tag;
return txt?(base+' ('+txt+')'):base;
}
function refresh(){
const vh = window.innerHeight;
const raw = [];
const seen = new Set();
let full = false;
function consider(el){
if (full) return;
// Never track InsightUX's own injected UI (toolbar, sidebar, the
// gaze-dot canvas, the mouse tracker panel) as if it were page content.
if (OWN_UI_IDS.has(el.id)) return;
if (el.closest && el.closest(OWN_UI_SELECTOR)) return;
const tag = el.tagName.toLowerCase();
const explicit = !!(el.dataset && el.dataset.aoi);
if (!explicit){
const isAlways = (tag==='nav'||tag==='header'||tag==='footer'||tag==='img'||
tag==='video'||tag==='iframe'||
tag==='h1'||tag==='h2'||tag==='h3'||tag==='button'||tag==='figure') ||
(el.className && typeof el.className==='string' &&
/hero|banner|card/i.test(el.className));
if (!isAlways && !hasOwnText(el)) return;
}
const r = el.getBoundingClientRect();
if (r.width<MIN_W || r.height<MIN_H) return;
if (r.bottom<0 || r.top>vh) return;
const label = labelFor(el);
if (!label) return;
const key = label+'@'+Math.round(r.left)+','+Math.round(r.top);
if (seen.has(key)) return;
seen.add(key);
const pos = getComputedStyle(el).position;
raw.push({el:el, label:label, x:Math.round(r.left), y:Math.round(r.top),
w:Math.round(r.width), h:Math.round(r.height),
sticky:(pos==='sticky'||pos==='fixed')});
if (raw.length >= MAX_AOIS*2) full = true;
}
document.querySelectorAll('[data-aoi]').forEach(consider);
document.querySelectorAll(ALWAYS_SELECTOR).forEach(consider);
const candidates = document.querySelectorAll(TEXT_CONTAINER_SELECTOR);
for (let i=0; i<candidates.length && i<MAX_SCAN && !full; i++){
consider(candidates[i]);
}
const filtered = raw.filter(o => {
const isTallWrapper = o.h > TALL_WRAPPER_H &&
raw.some(o2 => o2 !== o && o.el.contains(o2.el));
return !isTallWrapper;
});
state.aois = filtered.slice(0, MAX_AOIS).map(o => ({
label:o.label, x:o.x, y:o.y, w:o.w, h:o.h, sticky:o.sticky
}));
}
refresh();
window.addEventListener('scroll', refresh, {passive:true});
setInterval(refresh, 400);
// window.scrollY/document.documentElement.scrollHeight assume <html> is
// the element that actually scrolls. Some real-world pages instead put
// overflow/height on <body> (or trigger the browser's legacy quirks-mode
// scrolling element), which leaves window.scrollY pinned at 0 forever
// even while the page visibly scrolls -- the classic documentElement-vs-
// body split. Falling back to document.body's own scroll metrics
// whenever the documentElement ones read exactly 0 costs nothing on
// pages where documentElement was already correct (body's would-be
// fallback value only gets used when the primary is falsy) and fixes it
// on pages where it wasn't.
function scrollPos(){
const sx = window.scrollX || document.documentElement.scrollLeft || document.body.scrollLeft || 0;
const sy = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
return { x: sx, y: sy };
}
function pageSize(){
return {
w: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth),
h: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight),
};
}
// Elements with position:fixed/sticky stay pinned to the same spot in
// every viewport-sized screenshot regardless of scroll depth (a "Brochure"
// side tab, a floating CTA, a persistent nav) — the report's full-page
// stitcher (analysis.py/buildFullPageStitch) needs to know about them so
// it can paste each one only once instead of once per screenshot it was
// captured in. AOI candidates (state.aois above) don't cover this: that
// list only includes elements matching the attention-worthy-content
// heuristics (headings, images, text blocks, ...), so a plain nav tab
// never becomes an AOI candidate and its stickiness is never even
// evaluated. This is a separate, purpose-built scan, capped by the same
// MAX_SCAN budget as the AOI scan above and capped again on result count
// since fixed/sticky elements are rare on a normal page.
const MAX_STICKY = 20;
function stickyRects(){
const vw = window.innerWidth, vh = window.innerHeight;
const all = document.querySelectorAll('*');
const out = [];
for (let i = 0; i < all.length && i < MAX_SCAN && out.length < MAX_STICKY; i++){
const el = all[i];
if (OWN_UI_IDS.has(el.id) || (el.closest && el.closest(OWN_UI_SELECTOR))) continue;
const pos = getComputedStyle(el).position;
if (pos !== 'fixed' && pos !== 'sticky') continue;
const r = el.getBoundingClientRect();
if (r.width < MIN_W || r.height < MIN_H) continue;
if (r.bottom <= 0 || r.top >= vh || r.right <= 0 || r.left >= vw) continue;
out.push({
x: Math.round(Math.max(0, r.left)), y: Math.round(Math.max(0, r.top)),
w: Math.round(Math.min(vw, r.right) - Math.max(0, r.left)),
h: Math.round(Math.min(vh, r.bottom) - Math.max(0, r.top)),
});
}
return out;
}
window.insightuxAOIs = function(){
const pos = scrollPos();
const size = pageSize();
return JSON.stringify({
url: location.href,
title: document.title || '',
scrollX: Math.round(pos.x),
scrollY: Math.round(pos.y),
viewport:{w:window.innerWidth,h:window.innerHeight},
page:{w:size.w,h:size.h},
aois: state.aois,
stickyRects: stickyRects()
});
};
window.insightuxUpdate = function(fx, fy){
target.fx = fx;
target.fy = fy;
};
function renderLoop(){
const w = window.innerWidth, h = window.innerHeight;
const tx = target.fx * w, ty = target.fy * h;
if (dot.x === null){ dot.x = tx; dot.y = ty; }
dot.x += (tx - dot.x) * DOT_LERP;
dot.y += (ty - dot.y) * DOT_LERP;
const now = performance.now();
const px = dot.x, py = dot.y;
let cand = null;
for (const a of state.aois){
if (px>=a.x-PAD_PX && px<=a.x+a.w+PAD_PX && py>=a.y-PAD_PX && py<=a.y+a.h+PAD_PX){
if (!cand || (a.w*a.h)<(cand.w*cand.h)) cand = a;
}
}
const candLabel = cand ? cand.label : null;
if (candLabel !== dwell.pendLabel){ dwell.pendLabel = candLabel; dwell.pendSince = now; }
if (cand){
dwell.emptySince = 0;
if (dwell.activeLabel !== cand.label && (now - dwell.pendSince) >= DWELL_MS){
dwell.activeLabel = cand.label;
}
} else {
if (dwell.emptySince === 0) dwell.emptySince = now;
if (dwell.activeLabel && (now - dwell.emptySince) >= RELEASE_MS){
dwell.activeLabel = null;
}
}
let active = null;
if (dwell.activeLabel){
for (const a of state.aois){ if (a.label === dwell.activeLabel){ active = a; break; } }
if (!active) dwell.activeLabel = null;
}
activeBox = active;
ctx.clearRect(0,0,cv.width,cv.height);
if (activeBox){
ctx.save();
ctx.fillStyle = 'rgba(255,233,168,0.14)';
ctx.strokeStyle = '#FFE9A8'; ctx.lineWidth = 2.5;
roundRectPath(ctx, activeBox.x, activeBox.y, activeBox.w, activeBox.h, 8);
ctx.fill();
ctx.stroke();
ctx.restore();
const label = activeBox.label;
ctx.font = '600 12px -apple-system, "Segoe UI", Arial';
const tw = ctx.measureText(label).width + 20;
const ly = Math.max(0, activeBox.y - 26);
ctx.save();
ctx.fillStyle = '#FFE9A8';
roundRectPath(ctx, activeBox.x, ly, tw, 22, 11);
ctx.fill();
ctx.fillStyle = '#312F2E';
ctx.fillText(label, activeBox.x + 10, ly + 15);
ctx.restore();
}
// soft pulsing ring around the dot, on a slow independent cycle
const pulse = (Math.sin(now / 420) + 1) / 2;
ctx.beginPath();
ctx.arc(dot.x, dot.y, 15 + pulse * 5, 0, 2*Math.PI);
ctx.strokeStyle = 'rgba(255,233,168,' + (0.35 - pulse * 0.2) + ')';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.beginPath();
ctx.arc(dot.x, dot.y, 12, 0, 2*Math.PI);
ctx.fillStyle = 'rgba(255,255,255,0.92)';
ctx.fill();
ctx.beginPath();
ctx.arc(dot.x, dot.y, 8, 0, 2*Math.PI);
ctx.fillStyle = '#FFE9A8';
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = 'rgba(27,25,24,0.55)';
ctx.stroke();
requestAnimationFrame(renderLoop);
}
requestAnimationFrame(renderLoop);
window.__insightux = true;
})();
"""
# JS: mouse tracking overlay — ported feature-for-feature from the
# "Mouse Tracker & Heatmap" Chrome extension (Mouse/content.js + background.js
# + popup.js), adapted to run as a single injected script instead of a
# content-script/background/popup trio (pywebview has no extension host).
# Same trail/heatmap/dwell sampling loop, same click interest labeling, same
# heatmap render, plus an in-page panel that replaces the extension's popup
# (Start/Stop/Clear/Toggle Heatmap/View Logs, timer, "Most Viewed Element").
# =============================================================================
MOUSE_JS = r"""
(function(){
if (window.__insightuxMouse) { return; }
window.__insightuxMouse = true;
let isTracking = true;
let canvas = null;
let panelOpen = false;
let logsOpen = false;
// Per-sync buffers (flushed to Python every 2s for on-disk persistence)
let trailBuffer = [];
let heatmapBuffer = [];
let clickBuffer = [];
let dwellBuffer = [];
// Cumulative in-page state (mirrors the extension's background.js state)
let allTrail = [];
let allHeatmap = [];
let allClicks = [];
let dwellTotals = {};
let sessionStart = Date.now();
let sessionStop = null;
let lastClientX = 0, lastClientY = 0;
let lastPageX = 0, lastPageY = 0;
let lastSampleTime = 0;
const sampleRate = 50;
const DWELL_THRESHOLD = 5000;
let stationaryStart = 0;
let isStationary = false;
window.insightuxMouseSetTracking = function(on){
isTracking = !!on;
if (isTracking) {
sessionStart = Date.now();
sessionStop = null;
} else {
sessionStop = Date.now();
flushDwell();
}
updatePanel();
};
// ---- sampling loop (trail while moving, heatmap dwell points while still) ----
setInterval(function(){
if (!isTracking) return;
// Same documentElement-vs-body fallback as TRACKING_JS's scrollPos()
// (see its comment) — this loop reconstructs page-absolute coordinates
// manually (clientX/Y + scroll offset) instead of using the browser's
// own event.pageX/Y (which the click/hover handlers below use and
// don't need this), so it needs the same defensive read.
const currentScrollX = window.scrollX || document.documentElement.scrollLeft || document.body.scrollLeft || 0;
const currentScrollY = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
if (lastPageX === 0 && lastPageY === 0 && lastClientX !== 0) {
lastPageX = lastClientX + currentScrollX;
lastPageY = lastClientY + currentScrollY;
}
const currentPageX = (lastClientX !== 0) ? (lastClientX + currentScrollX) : lastPageX;
const currentPageY = (lastClientY !== 0) ? (lastClientY + currentScrollY) : lastPageY;
if (currentPageX === 0 && currentPageY === 0) return;
const now = Date.now();
const dt = now - lastSampleTime;
if (dt > 1000) { lastSampleTime = now; return; }
const dx = currentPageX - lastPageX, dy = currentPageY - lastPageY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 2) {
const pt = { x: currentPageX, y: currentPageY };
trailBuffer.push(pt); allTrail.push(pt);
lastPageX = currentPageX; lastPageY = currentPageY;
isStationary = false; stationaryStart = 0;
} else {
if (!isStationary) { isStationary = true; stationaryStart = now; }
else {
const dwellDuration = now - stationaryStart;
if (dwellDuration > DWELL_THRESHOLD) {
const pt = { x: currentPageX, y: currentPageY };
heatmapBuffer.push(pt); allHeatmap.push(pt);
}
}
}
lastSampleTime = now;
}, sampleRate);
// ---- dwell / element-interest tracking ----
let currentHoverLabel = null, currentHoverStartTime = 0;
function handleHoverChange(newLabel){
if (!isTracking) return;
if (newLabel !== currentHoverLabel) {
const now = Date.now();
if (currentHoverLabel && currentHoverStartTime > 0) {
const duration = now - currentHoverStartTime;
if (duration > 10) recordDwell(currentHoverLabel, duration);
}
currentHoverLabel = newLabel;
currentHoverStartTime = newLabel ? now : 0;
}
}
function recordDwell(element, duration){
dwellBuffer.push({ element: element, duration: duration });
dwellTotals[element] = (dwellTotals[element] || 0) + duration;
}
function flushDwell(){
if (currentHoverLabel && currentHoverStartTime > 0) {
const now = Date.now();
const duration = now - currentHoverStartTime;
if (duration > 50) recordDwell(currentHoverLabel, duration);
currentHoverStartTime = now;
}
}
// ---- sync to Python -> mouse_log.jsonl in the session folder ----
// Named so the click handler below can call it directly instead of
// waiting for the next tick: a click that opens a link navigates
// immediately, which tears down this page's JS (and whatever's still
// sitting in clickBuffer) before a 2s-only interval would ever get to
// send it — the click simply never reaches mouse_log.jsonl. Calling
// this right after a click is captured, in addition to the periodic
// tick for everything else (trail/heatmap/dwell), closes that race.
function syncMouseBatch(){
flushDwell();
if (trailBuffer.length || heatmapBuffer.length || clickBuffer.length || dwellBuffer.length) {
const payload = {
trail: trailBuffer.length ? trailBuffer : null,
heatmap: heatmapBuffer.length ? heatmapBuffer : null,
click: clickBuffer.length ? clickBuffer : null,
dwell: dwellBuffer.length ? dwellBuffer : null,
};
trailBuffer = []; heatmapBuffer = []; clickBuffer = []; dwellBuffer = [];
if (window.pywebview && window.pywebview.api && window.pywebview.api.log_mouse_data) {
try { window.pywebview.api.log_mouse_data(payload).catch(function(){}); } catch (e) {}
}
}
updatePanel();
}
setInterval(syncMouseBatch, 2000);
// ---- exclude InsightUX's own injected UI from every measurement ----
// (the toolbar, sidebar, mouse-tracker panel, heatmap canvas) — only
// real website elements should ever show up in trail/heatmap/dwell/clicks.
const OWN_UI_IDS = new Set(['__insightux_toolbar', '__insightux_sidebar', '__insightux_pill', '__insightux_canvas', '__insightux_mouse_panel']);
const OWN_UI_SELECTOR = '#__insightux_toolbar, #__insightux_sidebar, #__insightux_pill, #__insightux_canvas, #__insightux_mouse_panel';
function isOwnUI(el){
if (!el) return false;
if (OWN_UI_IDS.has(el.id)) return true;
return !!(el.closest && el.closest(OWN_UI_SELECTOR));
}
// ---- mouse position + hover tracking ----
function updateMousePos(e){
if (isOwnUI(e.target)) return;
lastClientX = e.clientX; lastClientY = e.clientY;
if (lastPageX === 0) lastPageX = e.pageX;
if (lastPageY === 0) lastPageY = e.pageY;
if (isTracking) handleHoverChange(getSmartLabel(e.target));
}
document.addEventListener('mousemove', updateMousePos, true);
document.addEventListener('mouseenter', updateMousePos, true);
document.addEventListener('mouseover', updateMousePos, true);
document.addEventListener('click', updateMousePos, true);
document.addEventListener('mouseleave', function(){
lastClientX = 0; lastClientY = 0;
handleHoverChange(null);
}, true);
document.addEventListener('click', function(e){
if (!isTracking) return;
if (e.target === canvas) return;
if (isOwnUI(e.target)) return;
if (!isInteractable(e.target)) return;
const x = e.pageX, y = e.pageY;
const label = getSmartLabel(e.target) || e.target.tagName;
if (['BODY', 'HTML', 'DIV', 'SPAN'].includes(label) && !e.target.innerText.trim()) return;
const logEntry = {
timestamp: new Date().toLocaleTimeString(),
x: x, y: y, element: label,
text: e.target.innerText ? e.target.innerText.substring(0, 30).replace(/(\r\n|\n|\r)/gm, ' ').trim() : '',
url: window.location.href
};
clickBuffer.push(logEntry); allClicks.push(logEntry);
if (canvas) drawClick(x, y);
syncMouseBatch(); // send immediately — this click may trigger a navigation right after it returns
}, true);
function isInteractable(el){
if (!el) return false;
const tag = el.tagName.toLowerCase();
if (['a', 'button', 'input', 'select', 'textarea', 'details', 'summary', 'label'].includes(tag)) return true;
const role = el.getAttribute('role');
if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') return true;
try { if (window.getComputedStyle(el).cursor === 'pointer') return true; } catch (e) {}
let parent = el.parentElement, depth = 0;
while (parent && depth < 3) {
const pTag = parent.tagName.toLowerCase();
if (['a', 'button'].includes(pTag)) return true;
if (parent.getAttribute('role') === 'button') return true;
parent = parent.parentElement; depth++;
}
if (tag === 'code' || tag === 'pre' || el.classList.contains('code') || el.closest('pre')) return true;
return false;
}
function getSmartLabel(el){
if (!el) return null;
const tag = el.tagName.toLowerCase();
if (['body', 'html', 'main', 'div', 'span', 'section', 'article'].includes(tag)) {
if (el.id && (el.id.includes('logo') || el.id.includes('wrapper') || el.id.includes('container'))) return null;
if (el.className && typeof el.className === 'string' &&
(el.className.toLowerCase().includes('logo') || el.className.toLowerCase().includes('brand'))) return null;
const aria = el.getAttribute('aria-label');
if (aria) return 'Element: ' + aria;
if (el.children.length === 0) {
const txt = el.innerText.trim();
if (txt.length > 2 && txt.length < 50 && /[a-zA-Z0-9]/.test(txt)) return 'Element: ' + txt;
}
return null;
}
if (tag === 'a') return 'Link: ' + (el.innerText.trim().substring(0, 30) || 'Link');
if (tag === 'button') return 'Button: ' + (el.innerText.trim().substring(0, 30) || 'Button');
if (tag === 'input') return 'Input: ' + (el.placeholder || el.name || el.id || 'Input');
if (tag === 'textarea') return 'Input: Text Area';
if (tag === 'img') return 'Image: ' + (el.alt || 'Image');
if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag)) return tag.toUpperCase() + ': ' + el.innerText.trim().substring(0, 40);
if (tag === 'code' || tag === 'pre') return 'Code: ' + el.innerText.trim().substring(0, 30);
const text = el.innerText.trim();
if (text && text.length > 2) {
if (el.classList.contains('code') || el.closest('pre')) return 'Code: ' + text.substring(0, 30);
if (text.toLowerCase().includes('no message found')) return null;
const isHex = /^#[0-9A-F]{6}$/i.test(text) || /^#[0-9A-F]{3}$/i.test(text);
const isColorName = ['red', 'blue', 'green', 'yellow', 'black', 'white', 'orange', 'purple', 'gray', 'grey', 'pink', 'brown', 'cyan', 'magenta'].includes(text.toLowerCase());
if (isHex || isColorName) return null;
if (text.length > 50) return 'Text: ' + text.substring(0, 47) + '...';
return 'Text: ' + text;
}
return null;
}
// ---- heatmap overlay canvas (ported 1:1 from the extension) ----
let heatOpacity = 0.85, heatIntensity = 0.05;
window.insightuxMouseToggleHeatmap = function(){
if (canvas) { document.body.removeChild(canvas); canvas = null; return; }
canvas = document.createElement('canvas');
canvas.style.position = 'absolute';
canvas.style.top = '0'; canvas.style.left = '0';
canvas.style.zIndex = '2147483645';
canvas.style.pointerEvents = 'none';
canvas.style.opacity = heatOpacity;
canvas.style.transition = 'opacity .15s ease';
canvas.width = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, document.documentElement.offsetWidth);
canvas.height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, document.documentElement.offsetHeight);
document.body.appendChild(canvas);
drawHeatmapHighQuality(allHeatmap, allTrail, allClicks);
};
window.insightuxMouseSetHeatStyle = function(opacity, intensity){
if (opacity != null) { heatOpacity = opacity; if (canvas) canvas.style.opacity = heatOpacity; }
if (intensity != null) { heatIntensity = intensity; if (canvas) drawHeatmapHighQuality(allHeatmap, allTrail, allClicks); }
};
function drawClick(x, y){
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.save();
ctx.beginPath();
ctx.strokeStyle = '#00FF00'; ctx.lineWidth = 3;
ctx.shadowColor = 'black'; ctx.shadowBlur = 2;
const size = 10;
ctx.moveTo(x - size, y - size); ctx.lineTo(x + size, y + size);
ctx.moveTo(x + size, y - size); ctx.lineTo(x - size, y + size);
ctx.stroke();
ctx.beginPath(); ctx.arc(x, y, size + 5, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
function drawHeatmapHighQuality(heatmapData, trailData, clickData){
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
let allHeatPoints = [];
if (heatmapData && heatmapData.length) allHeatPoints = allHeatPoints.concat(heatmapData);
if (trailData && trailData.length) allHeatPoints = allHeatPoints.concat(trailData);
if (clickData && clickData.length) {
clickData.forEach(function(c){ for (let i = 0; i < 5; i++) allHeatPoints.push({ x: c.x, y: c.y }); });
}
if (!allHeatPoints.length) return;
const radius = 24; // smaller, more precise spots (was 60)
const brushCanvas = document.createElement('canvas');
brushCanvas.width = radius * 2; brushCanvas.height = radius * 2;
const brushCtx = brushCanvas.getContext('2d');
const g = brushCtx.createRadialGradient(radius, radius, 0, radius, radius, radius);
g.addColorStop(0, 'rgba(0, 0, 0, ' + heatIntensity + ')'); g.addColorStop(1, 'rgba(0, 0, 0, 0)');
brushCtx.fillStyle = g; brushCtx.fillRect(0, 0, radius * 2, radius * 2);
allHeatPoints.forEach(function(point){ ctx.drawImage(brushCanvas, point.x - radius, point.y - radius); });
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const gradientMap = createGradientMap();
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha > 0) {
let mapIndex = Math.floor(alpha * 1.5);
if (mapIndex > 255) mapIndex = 255;
const cIndex = mapIndex * 4;
data[i] = gradientMap[cIndex]; data[i + 1] = gradientMap[cIndex + 1]; data[i + 2] = gradientMap[cIndex + 2];
data[i + 3] = Math.min(255, 150 + alpha);
}
}
ctx.putImageData(imageData, 0, 0);
}
function createGradientMap(){
const c = document.createElement('canvas'); c.width = 256; c.height = 1;
const ctx = c.getContext('2d');
const g = ctx.createLinearGradient(0, 0, 256, 0);
g.addColorStop(0.0, 'rgba(0, 0, 255, 0)');
g.addColorStop(0.1, 'rgba(0, 0, 255, 1)');
g.addColorStop(0.4, 'rgba(0, 255, 255, 1)');
g.addColorStop(0.6, 'rgba(0, 255, 0, 1)');
g.addColorStop(0.8, 'rgba(255, 255, 0, 1)');
g.addColorStop(1.0, 'rgba(255, 0, 0, 1)');
ctx.fillStyle = g; ctx.fillRect(0, 0, 256, 1);
return ctx.getImageData(0, 0, 256, 1).data;
}
window.addEventListener('resize', function(){
if (canvas) {
canvas.width = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, document.documentElement.offsetWidth);
canvas.height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, document.documentElement.offsetHeight);
}
});
// ---- insights panel: in-page port of the extension's popup.html/popup.js ----
__ICONS_JS__
const style = document.createElement('style');
style.textContent = `
__THEME_CSS__
#__insightux_mouse_panel {
position: fixed; top: 66px; right: 16px; width: 268px; z-index: 2147483647;
border-radius: var(--iux-radius); box-shadow: var(--iux-shadow);
font: 12px var(--iux-font); color: var(--iux-text); padding: 14px;
pointer-events: auto; display: none;
transform: translateY(-8px); opacity: 0; transition: transform .2s var(--iux-ease), opacity .2s var(--iux-ease);
}
#__insightux_mouse_panel.open { display: block; }
#__insightux_mouse_panel.show { transform: translateY(0); opacity: 1; }
#__insightux_mouse_panel h3 { margin: 0 0 10px 0; font-size: 12.5px; color: var(--iux-text); font-weight: 600;
display:flex; align-items:center; gap:6px; }
#__insightux_mouse_panel .muted { color: var(--iux-text-faint); font-size: 11px; }
#__insightux_mouse_panel .row { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
#__insightux_mouse_panel .row .iux-btn {
flex: 1 1 auto; padding: 7px 6px; font-size: 10.5px; gap: 4px;
}
#__insightux_mouse_panel .row .iux-btn.active { background: var(--iux-accent-grad); border-color: transparent; color: var(--iux-on-accent); }
#__insightux_mouse_panel .timer { font-weight: 600; margin-bottom: 10px; display:flex; align-items:center; gap:6px; color: var(--iux-text-dim); }
#__insightux_mouse_panel .hero {
background: linear-gradient(135deg, color-mix(in srgb, var(--iux-primary) 16%, transparent), color-mix(in srgb, var(--iux-indigo) 10%, transparent));
border: 1px solid var(--iux-border); border-radius: var(--iux-radius-sm);
padding: 10px; text-align: center; margin-bottom: 10px;
}
#__insightux_mouse_panel .hero .lbl { font-size: 9px; text-transform: uppercase; color: var(--iux-text-faint); letter-spacing: 0.06em; }
#__insightux_mouse_panel .hero .el { font-weight: 700; margin: 4px 0; color: var(--iux-primary-light); }
#__insightux_mouse_panel .interest-item, #__insightux_mouse_panel .log-item {
background: var(--iux-surface-hi); border-radius: 8px; padding: 6px 8px; margin-bottom: 5px; font-size: 11px;
}
#__insightux_mouse_panel .list { max-height: 160px; overflow-y: auto; }
#__insightux_mouse_panel .sliders { display: none; margin-bottom: 10px; }
#__insightux_mouse_panel .sliders.open { display: block; }
#__insightux_mouse_panel .slider-row { display:flex; align-items:center; gap:8px; margin-bottom:6px; font-size: 10.5px; color: var(--iux-text-dim); }
#__insightux_mouse_panel .slider-row span { flex: 0 0 52px; }
#__insightux_mouse_panel input[type=range] { flex: 1 1 auto; accent-color: var(--iux-primary-light); }
`;
document.head.appendChild(style);
const panel = document.createElement('div');
panel.id = '__insightux_mouse_panel';
panel.className = 'iux-glass';
panel.innerHTML = `
<h3>${iuxIcon('cursor', 14)} Mouse Tracker <span class="muted" id="__mt_status" style="margin-left:auto;">idle</span></h3>
<div class="timer">${iuxIcon('clock', 13)} <span id="__mt_timer">00:00</span></div>
<div class="row">
<button class="iux-btn" id="__mt_clear" title="Clear collected data">${iuxIcon('trash', 13)} Clear</button>
<button class="iux-btn" id="__mt_heatmap" title="Toggle heatmap overlay (H)">${iuxIcon('layers', 13)} Heatmap</button>
<button class="iux-btn" id="__mt_logs" title="View click log">${iuxIcon('type', 13)} Logs</button>
</div>
<div class="sliders" id="__mt_sliders">
<div class="slider-row"><span>Opacity</span><input type="range" id="__mt_opacity" min="20" max="100" value="85"></div>
<div class="slider-row"><span>Intensity</span><input type="range" id="__mt_intensity" min="2" max="12" value="5"></div>
</div>
<div id="__mt_hero" class="hero" style="display:none;">
<div class="lbl">Most Viewed Element</div>
<div class="el" id="__mt_hero_el">-</div>
<div id="__mt_hero_time">0s</div>
</div>
<div id="__mt_interests" class="list"></div>
<div id="__mt_logs_list" class="list" style="display:none;"></div>
`;
document.documentElement.appendChild(panel);
requestAnimationFrame(function(){ panel.classList.add('show'); });
document.getElementById('__mt_clear').addEventListener('click', function(){
trailBuffer = []; heatmapBuffer = []; clickBuffer = []; dwellBuffer = [];
allTrail = []; allHeatmap = []; allClicks = []; dwellTotals = {};
sessionStart = Date.now(); sessionStop = null;
if (canvas) { document.body.removeChild(canvas); canvas = null; }
updatePanel();
});
const heatmapBtn = document.getElementById('__mt_heatmap');
const slidersEl = document.getElementById('__mt_sliders');
heatmapBtn.addEventListener('click', function(){
window.insightuxMouseToggleHeatmap();
heatmapBtn.classList.toggle('active', !!canvas);
slidersEl.classList.toggle('open', !!canvas);
});
document.getElementById('__mt_opacity').addEventListener('input', function(e){
window.insightuxMouseSetHeatStyle(e.target.value / 100, null);
});
document.getElementById('__mt_intensity').addEventListener('input', function(e){
window.insightuxMouseSetHeatStyle(null, e.target.value / 100);
});
document.getElementById('__mt_logs').addEventListener('click', function(){
logsOpen = !logsOpen;
document.getElementById('__mt_logs').classList.toggle('active', logsOpen);
document.getElementById('__mt_logs_list').style.display = logsOpen ? 'block' : 'none';
document.getElementById('__mt_interests').style.display = logsOpen ? 'none' : 'block';
updatePanel();
});
window.insightuxMouseTogglePanel = function(){
panelOpen = !panelOpen;
panel.classList.toggle('open', panelOpen);
if (panelOpen) updatePanel();
};
function formatTime(ms){ return Math.round(ms / 1000) + 's'; }
function updatePanel(){
if (!panelOpen) return;
document.getElementById('__mt_status').textContent = isTracking ? 'tracking' : 'stopped';
const startTs = sessionStart;
const diff = isTracking ? Math.floor((Date.now() - startTs) / 1000)
: (sessionStop ? Math.floor((sessionStop - startTs) / 1000) : 0);
const mins = Math.floor(diff / 60).toString().padStart(2, '0');
const secs = (diff % 60).toString().padStart(2, '0');
document.getElementById('__mt_timer').textContent = 'Session Time: ' + mins + ':' + secs;
const interests = Object.entries(dwellTotals).map(function(e){ return { element: e[0], duration: e[1] }; })
.sort(function(a, b){ return b.duration - a.duration; }).slice(0, 6);
const hero = document.getElementById('__mt_hero');
if (interests.length) {
hero.style.display = 'block';
document.getElementById('__mt_hero_el').textContent = interests[0].element;
document.getElementById('__mt_hero_time').textContent = formatTime(interests[0].duration);
} else {
hero.style.display = 'none';
}
const interestsEl = document.getElementById('__mt_interests');
if (!logsOpen) {
if (interests.length > 1) {
interestsEl.innerHTML = interests.slice(1).map(function(it, i){
return '<div class="interest-item">' + (i + 2) + '. <b>' + it.element + '</b> — ' + formatTime(it.duration) + '</div>';
}).join('');
} else {
interestsEl.innerHTML = '<div class="muted">No other interests yet.</div>';
}
}
const logsEl = document.getElementById('__mt_logs_list');
if (logsOpen) {
if (allClicks.length) {
logsEl.innerHTML = allClicks.slice().reverse().slice(0, 20).map(function(l){
return '<div class="log-item"><span class="muted">' + l.timestamp + '</span><br><b>' + l.element + '</b><br>"' + l.text + '"</div>';
}).join('');
} else {
logsEl.innerHTML = '<div class="muted">No clicks recorded.</div>';
}
}
}
setInterval(updatePanel, 1000);
// ---- hotkeys: H = toggle heatmap, M = toggle insights panel ----
function isTypingTarget(el){
if (!el) return false;
const tag = el.tagName ? el.tagName.toLowerCase() : '';
return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable === true;
}
document.addEventListener('keydown', function(e){
const typingBlocked = isTypingTarget(e.target) || isTypingTarget(document.activeElement);
if (typingBlocked) return;
if (e.key === 'h' || e.key === 'H') window.insightuxMouseToggleHeatmap();
else if (e.key === 'm' || e.key === 'M') window.insightuxMouseTogglePanel();
}, true);
updatePanel();
})();
"""
MOUSE_JS = MOUSE_JS.replace("__ICONS_JS__", theme.ICONS_JS)
MOUSE_JS = MOUSE_JS.replace("__THEME_CSS__", theme.THEME_CSS)
# =============================================================================
# API exposed to JS — start/stop/quit are the entry points
# =============================================================================
# Pages tracking must never start on, checked against window.get_current_url()
# as a Python-side backstop to CHROME_JS's isTrackable check (belt and
# suspenders — the JS guard can't run before the JS bridge is up).
_NON_TRACKABLE_URL_MARKERS = ("insightux_landing.html", "analysis_report.html", "insightux_login.html")
def _relaunch_args(mode):
"""Command to spawn calibrate.py/validate.py's logic in a fresh process.
Frozen (PyInstaller) builds have no bundled interpreter to hand a .py
file to, and sys.executable IS the frozen exe itself — so a frozen build
relaunches itself with --mode instead. Unpackaged/dev mode still runs
from the venv exactly as before."""
if getattr(sys, "frozen", False):
return [sys.executable, "--mode", mode]
return [sys.executable, os.path.abspath(__file__), "--mode", mode]
class Api:
def __init__(self):
self.window = None
self.tracking = False
self.session_started_at = None
self.current_session_dir = None # the folder gaze_worker() is actively writing into right now, else None — the one session delete_*_session() must refuse to touch
self.stop_event = threading.Event()
self.thread = None
self.mouse_log_f = None
self.mouse_close_timer = None
self.calib_proc = None
self.validate_proc = None
self.update_info = None
self.current_user = None # None until login()/create_profile() succeeds
self.active_tracking_subject = None # None = tracking "Myself"; else a participant dict
def _notify_update(self, version, url):
try:
self.window.evaluate_js(
f"window.insightuxShowUpdate && window.insightuxShowUpdate("
f"{json.dumps(version)}, {json.dumps(url)})"
)
except Exception:
pass
@_require_token
def open_update_page(self, url):
_log(f"[browser_session] open_update_page() called from JS: {url}")
import webbrowser
try:
webbrowser.open(url)
except Exception as e:
_log(f"[browser_session] failed to open update url: {e}")
return True
# -- profiles -------------------------------------------------------
# InsightUX has no server/database — auth.py's users.json *is* the
# backend here. The frontend never supplies a user id as proof of
# anything: list_profiles() only ever returns public fields (never a
# password hash), and login()/create_profile() are the only two ways
# self.current_user gets set, both gated on auth.verify_login()/
# auth.create_user() actually succeeding server-side (i.e. in this
# Python process). Every other per-user read/write in the app goes
# through SESSIONS_ROOT/CALIBRATION_PATH/_user_onnx_path(), which only
# _apply_user_paths() (called from here) is allowed to repoint.
def list_profiles(self):
return auth.list_profiles(DATA_DIR)
def login(self, user_id, password):
_log(f"[browser_session] login() called from JS for user_id={user_id}")
try:
profile = auth.verify_login(DATA_DIR, user_id, password)
except auth.AuthError as e:
_log(f"[browser_session] login failed: {e}")
return {"ok": False, "error": str(e)}
self._activate_user(profile)
return {"ok": True}
def create_profile(self, name, email, password):
_log(f"[browser_session] create_profile() called from JS for email={email}")
was_first_ever = not auth.load_users(DATA_DIR)
try:
profile = auth.create_user(DATA_DIR, name, email, password)
except auth.AuthError as e:
_log(f"[browser_session] create_profile failed: {e}")
return {"ok": False, "error": str(e)}
if was_first_ever:
try:
_migrate_legacy_data(profile["id"])
except Exception as e:
_log(f"[browser_session] legacy data migration failed (non-fatal): {e}")
self._activate_user(profile)
return {"ok": True}
def _activate_user(self, profile):
"""The one place 'current user' and every per-user path flip
together, atomically — used by both login() and create_profile()
so there's never a window where the paths point at one profile
while current_user says another. Every login/switch starts back
in "Myself" mode — the previous session's tracking subject (if
any) never carries over to a different signed-in owner."""
self.current_user = profile
self.active_tracking_subject = None
_apply_user_paths(profile["id"])
_apply_subject_paths(None)
self._set_profile_ui()
self._set_subject_ui()
try:
fresh_url = _write_landing_page()
self.window.load_url(fresh_url)
except Exception as e:
_log(f"[browser_session] navigating to landing page after login failed: {e}")
@_require_token
def switch_profile_screen(self):
"""'Switch profile' in the profile popover. Waits for any active
tracking session to fully finish (report included, same as
letting it stop on its own) before showing the picker, so the
session Log out/Switch never races the auto-navigate-to-report
step already built into gaze_worker()."""
_log("[browser_session] switch_profile_screen() called from JS")
self._stop_tracking_and_wait()
try:
fresh_url = _write_login_page()
self.window.load_url(fresh_url)
except Exception as e:
_log(f"[browser_session] switch_profile_screen navigation failed: {e}")
return True
@_require_token
def logout(self):
_log("[browser_session] logout() called from JS")
self._stop_tracking_and_wait()
self.current_user = None
self.active_tracking_subject = None
self._clear_user_paths()
try:
fresh_url = _write_login_page()
self.window.load_url(fresh_url)
except Exception as e:
_log(f"[browser_session] logout navigation failed: {e}")
return True
def _stop_tracking_and_wait(self):
"""Shared by logout()/switch_profile_screen(): stop_tracking()
itself only signals the tracking thread and returns immediately
(same as pressing E) — join it here so the thread's own
report-generation + window navigation (see gaze_worker()) has
already happened before we navigate to the login page ourselves,
instead of the two racing for the last word on where the window
ends up. A bounded timeout keeps logout/switch from ever hanging
indefinitely if report generation is unusually slow."""
if not self.tracking:
return
self.stop_tracking.__wrapped__(self)
if self.thread and self.thread.is_alive():
self.thread.join(timeout=8)
def _clear_user_paths(self):
"""Repoints the per-user constants at a location that's
guaranteed never to exist, so that if anything were ever read
through them while logged out it fails safe (finds nothing)
instead of silently reading whichever profile was active last."""
global SESSIONS_ROOT, CALIBRATION_PATH, CURRENT_USER_DIR, CURRENT_SUBJECT_DIR
CURRENT_USER_DIR = None
CURRENT_SUBJECT_DIR = None
SESSIONS_ROOT = os.path.join(DATA_DIR, "_no_active_profile", "sessions")
CALIBRATION_PATH = os.path.join(DATA_DIR, "_no_active_profile", "calibration.pkl")
def _set_profile_ui(self):
try:
payload = self.current_user or {}
self.window.evaluate_js(
f"window.insightuxSetProfile && window.insightuxSetProfile({json.dumps(payload)})"
)
except Exception:
pass
# -- participants ----------------------------------------------------
# A participant is a tracking SUBJECT, not an InsightUX login — they
# never authenticate. Isolation instead comes from where their data
# physically lives: participants.participant_dir() only ever resolves
# to users/<CURRENT_USER_DIR>/participants/<id>/, so a participant of
# one owner can never be reached while a different owner is signed in
# (there is no cross-owner participant_id lookup — the folder simply
# doesn't exist under the wrong owner).
@_require_token
def list_participants(self):
if not CURRENT_USER_DIR:
return []
return participants.list_participants(CURRENT_USER_DIR)
@_require_token
def add_participant(self, name, notes=""):
if not CURRENT_USER_DIR:
return {"ok": False, "error": "Sign in first."}
try:
record = participants.create_participant(CURRENT_USER_DIR, name, notes)
except participants.ParticipantError as e:
return {"ok": False, "error": str(e)}
return {"ok": True, "participant": record}
@_require_token
def update_participant(self, participant_id, name, notes=""):
if not CURRENT_USER_DIR:
return {"ok": False, "error": "Sign in first."}
try:
record = participants.update_participant(CURRENT_USER_DIR, participant_id, name=name, notes=notes)
except participants.ParticipantError as e:
return {"ok": False, "error": str(e)}
# Keep the in-memory "who's actively selected" copy (name shown in
# the sidebar's Participant panel / status line) in sync — without
# this a rename wouldn't show up there until a Myself<->them
# round trip re-read it from disk.
if self.active_tracking_subject and self.active_tracking_subject.get("id") == participant_id:
self.active_tracking_subject = record
self._set_subject_ui()
return {"ok": True, "participant": record}
@_require_token
def delete_participant(self, participant_id):
if not CURRENT_USER_DIR:
return {"ok": False, "error": "Sign in first."}
if self.tracking and self.active_tracking_subject and self.active_tracking_subject.get("id") == participant_id:
return {"ok": False, "error": "Stop the current session (E) before deleting this participant."}
try:
participants.delete_participant(CURRENT_USER_DIR, participant_id)
except participants.ParticipantError as e:
return {"ok": False, "error": str(e)}
if self.active_tracking_subject and self.active_tracking_subject.get("id") == participant_id:
# Deleted while merely selected (not mid-session) -- fall back
# to "Myself" rather than leaving SESSIONS_ROOT/CALIBRATION_PATH
# pointed at a folder that no longer has a participant record
# behind it.
self.active_tracking_subject = None
_apply_subject_paths(None)
self._set_subject_ui()
return {"ok": True}
@_require_token
def set_tracking_subject(self, participant_id):
"""participant_id falsy -> "Myself". Refuses mid-session, same
guard as calibration — switching subjects under a live session
would otherwise silently split one session's data across two
people's folders."""
_log(f"[browser_session] set_tracking_subject() called from JS: {participant_id!r}")
if self.tracking:
self._set_status("Stop the current session (E) before changing the tracking subject.")
return False
if not participant_id:
self.active_tracking_subject = None
else:
record = participants.get_participant(CURRENT_USER_DIR, participant_id) if CURRENT_USER_DIR else None
if not record:
self._set_status("That participant no longer exists.")
return False
self.active_tracking_subject = record
_apply_subject_paths(self.active_tracking_subject)
self._set_subject_ui()
who = self.active_tracking_subject["name"] if self.active_tracking_subject else "Myself"
self._set_status(f"Now tracking: {who}")
return True
def _set_subject_ui(self):
try:
payload = self.active_tracking_subject # None serializes to JS null -- "Myself"
self.window.evaluate_js(
f"window.insightuxSetSubject && window.insightuxSetSubject({json.dumps(payload)})"
)
except Exception:
pass
# -- session history: Profile -> My Sessions / (Three-dot) Persons ---
# Both always resolve an EXPLICIT folder, never the currently-active
# SESSIONS_ROOT — so "My Sessions" keeps showing the owner's own
# sessions and "Persons -> Rahul" keeps showing Rahul's, no matter who
# the Participant panel currently has selected. Each call does a fresh
# directory listing (_sessions_json()), so simply opening either panel
# is the "live refresh" — no separate polling/push needed.
@_require_token
def list_my_sessions(self, limit=25):
if not CURRENT_USER_DIR:
return []
return _sessions_json(os.path.join(CURRENT_USER_DIR, "sessions"), limit)
@_require_token
def list_participant_sessions(self, participant_id, limit=25):
if not CURRENT_USER_DIR or not participant_id:
return []
pdir = participants.participant_dir(CURRENT_USER_DIR, participant_id)
return _sessions_json(os.path.join(pdir, "sessions"), limit)
def _resolve_session_dir(self, sessions_root, session_id):
"""session_id round-trips through JS on every rename/open-report
call, and window.pywebview.api is reachable from ANY loaded page's
own script (not just our injected chrome) — reject anything that
doesn't look like a folder name we ourselves generated before it
ever touches os.path.join, instead of trusting it as a path
component."""
if not session_id or not _SESSION_ID_RE.match(session_id):
return None
session_dir = os.path.join(sessions_root, session_id)
if not os.path.isdir(session_dir):
return None
return session_dir
def _open_session_report(self, sessions_root, session_id):
"""Native window.load_url() call, not page script — mirrors
go_home() above: a page loaded over http(s) can't script-navigate
itself to a file:// URL (Chromium/WebView2 silently blocks it), so
"View Report" from My Sessions/Persons has to come in through here
to work while browsing a real website, not just from the landing
or report pages (which happen to already be file:// and were
masking this)."""
session_dir = self._resolve_session_dir(sessions_root, session_id)
if not session_dir:
return False
report_path = os.path.join(session_dir, "analysis_report.html")
if not os.path.exists(report_path):
return False
try:
self.window.load_url("file://" + report_path.replace(os.sep, "/"))
except Exception as e:
_log(f"[browser_session] open session report failed: {e}")
return False
return True
@_require_token
def open_my_session_report(self, session_id):
if not CURRENT_USER_DIR:
return False
return self._open_session_report(os.path.join(CURRENT_USER_DIR, "sessions"), session_id)
@_require_token
def open_participant_session_report(self, participant_id, session_id):
if not CURRENT_USER_DIR or not participant_id:
return False
pdir = participants.participant_dir(CURRENT_USER_DIR, participant_id)
return self._open_session_report(os.path.join(pdir, "sessions"), session_id)
def _export_session(self, sessions_root, session_id):
"""Bundles a JSON summary + a CSV of raw gaze samples into a single
.zip (one native Save dialog, one resulting file, rather than
making the researcher pick a destination twice or hunt for two
loose files afterwards) and writes it wherever the user chooses.
Read-only against the session itself -- never touches session_dir."""
session_dir = self._resolve_session_dir(sessions_root, session_id)
if not session_dir:
return False
try:
gaze, dom = load_session(session_dir)
summary = session_summary(gaze, dom)
mouse = summarize_mouse(session_dir)
except Exception as e:
_log(f"[browser_session] export: could not load session data: {e}")
return False
meta = {}
meta_path = os.path.join(session_dir, "session_meta.json")
if os.path.exists(meta_path):
try:
with open(meta_path, "r", encoding="utf-8") as f:
meta = json.load(f)
except Exception:
meta = {}
summary_json = {
"session_id": session_id,
"display_name": meta.get("display_name"),
"started_at": meta.get("started_at"),
"tracking_mode": meta.get("tracking_mode", "self"),
"participant_name": meta.get("participant_name"),
"url": summary["url"],
"duration_seconds": summary["duration"],
"gaze_samples": summary["samples"],
"mouse_clicks": mouse["click_count"],
"mouse_trail_points": mouse["trail_points"],
"mouse_heatmap_points": mouse["heatmap_points"],
"top_dwell_elements": mouse["interests"],
}
try:
# webview.FileDialog.SAVE doesn't exist in the pinned pywebview
# 4.4.1 (added in a later release) -- SAVE_DIALOG is the name
# that actually exists here, deprecated or not.
result = self.window.create_file_dialog(
webview.SAVE_DIALOG, directory="",
save_filename=f"insightux_session_{session_id}.zip",
file_types=("Zip files (*.zip)",),
)
except Exception as e:
_log(f"[browser_session] export: save dialog failed: {e}")
return False
if not result:
return False # user cancelled the dialog
# window.create_file_dialog()'s own type hint promises Sequence[str]
# -- but the pinned pywebview 4.4.1's WinForms backend returns a
# plain str for SAVE_DIALOG specifically (dialog.FileName directly,
# unlike OPEN_DIALOG/FOLDER_DIALOG which do wrap their result in a
# tuple). Trusting the type hint here silently wrote to a mangled
# one-character path (result[0] on a string is just its first
# character) instead of anywhere the user actually chose.
dest_path = result if isinstance(result, str) else result[0]
if not dest_path.lower().endswith(".zip"):
dest_path += ".zip"
try:
with zipfile.ZipFile(dest_path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("session_summary.json", json.dumps(summary_json, indent=2))
csv_buf = io.StringIO()
writer = csv.writer(csv_buf)
writer.writerow(["timestamp_seconds", "screen_x", "screen_y"])
for g in gaze:
writer.writerow([g.get("t"), g.get("sx"), g.get("sy")])
zf.writestr("gaze_samples.csv", csv_buf.getvalue())
except Exception as e:
_log(f"[browser_session] export: failed to write {dest_path}: {e}")
return False
_log(f"[browser_session] exported session {session_id} -> {dest_path}")
return True
@_require_token
def export_my_session(self, session_id):
if not CURRENT_USER_DIR:
return False
return self._export_session(os.path.join(CURRENT_USER_DIR, "sessions"), session_id)
@_require_token
def export_participant_session(self, participant_id, session_id):
if not CURRENT_USER_DIR or not participant_id:
return False
pdir = participants.participant_dir(CURRENT_USER_DIR, participant_id)
return self._export_session(os.path.join(pdir, "sessions"), session_id)
def _set_session_display_name(self, sessions_root, session_id, name):
"""Persisted to that session's own session_meta.json (as
display_name) instead of browser localStorage — localStorage is
scoped per web origin, so a name saved while looking at one
website would never show up while looking at another, or from the
file:// landing page. A file next to the session's own data is the
one place every view of it (My Sessions, Persons, the report
itself) can agree on. Sessions from before session_meta.json
existed just get a fresh one with only this field."""
session_dir = self._resolve_session_dir(sessions_root, session_id)
if not session_dir:
return False
meta_path = os.path.join(session_dir, "session_meta.json")
meta = {}
if os.path.exists(meta_path):
try:
with open(meta_path, "r", encoding="utf-8") as f:
meta = json.load(f)
except Exception:
meta = {}
trimmed = (name or "").strip()
if trimmed:
meta["display_name"] = trimmed
else:
meta.pop("display_name", None)
try:
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(meta, f, indent=2)
except Exception as e:
_log(f"[browser_session] could not save session display name: {e}")
return False
return True
@_require_token
def rename_my_session(self, session_id, name):
if not CURRENT_USER_DIR:
return False
return self._set_session_display_name(os.path.join(CURRENT_USER_DIR, "sessions"), session_id, name)
@_require_token
def rename_participant_session(self, participant_id, session_id, name):
if not CURRENT_USER_DIR or not participant_id:
return False
pdir = participants.participant_dir(CURRENT_USER_DIR, participant_id)
return self._set_session_display_name(os.path.join(pdir, "sessions"), session_id, name)
def _delete_session(self, sessions_root, session_id):
"""Irreversible; the caller is expected to have already confirmed
with the user. Refuses the one folder gaze_worker() might still
have file handles open on (self.current_session_dir) — rmtree'ing
a session mid-recording would race its own logger/screenshot
writes instead of just deleting old, already-closed data."""
session_dir = self._resolve_session_dir(sessions_root, session_id)
if not session_dir:
return False
if self.current_session_dir and os.path.normcase(os.path.normpath(session_dir)) == \
os.path.normcase(os.path.normpath(self.current_session_dir)):
return False
import shutil
try:
shutil.rmtree(session_dir)
except Exception as e:
_log(f"[browser_session] could not delete session: {e}")
return False
return True
@_require_token
def delete_my_session(self, session_id):
if not CURRENT_USER_DIR:
return False
return self._delete_session(os.path.join(CURRENT_USER_DIR, "sessions"), session_id)
@_require_token
def delete_participant_session(self, participant_id, session_id):
if not CURRENT_USER_DIR or not participant_id:
return False
pdir = participants.participant_dir(CURRENT_USER_DIR, participant_id)
return self._delete_session(os.path.join(pdir, "sessions"), session_id)
@_require_token
def toggle_session(self):
"""Single entry point for the sidebar's Start/Stop Session button —
lets the JS button stay dumb (always call the same thing) while
Python decides which action makes sense from current state.
Calls start_tracking()/stop_tracking() via .__wrapped__ (the
original undecorated function functools.wraps preserves) rather
than through self.start_tracking()/self.stop_tracking() directly —
those are themselves @_require_token, which expects the token as
the caller's last positional arg. This internal call passes none,
so going through the normal bound method would always be rejected
by that same decorator, silently no-op'ing the button."""
return self.stop_tracking.__wrapped__(self) if self.tracking else self.start_tracking.__wrapped__(self)
@_require_token
def start_tracking(self):
_log("[browser_session] start_tracking() called from JS")
if self.tracking:
_log("[browser_session] already tracking, ignoring")
return False
if self.current_user is None:
_log("[browser_session] refusing to start, no profile signed in")
self._set_status("Sign in to a profile before starting a session.")
return False
try:
current_url = self.window.get_current_url() or ""
except Exception:
current_url = ""
if any(marker in current_url for marker in _NON_TRACKABLE_URL_MARKERS):
_log(f"[browser_session] refusing to start on non-website page: {current_url}")
self._set_status("Start tracking only works on a website — search or open a page first.")
return False
if self._external_proc_running():
_log("[browser_session] refusing to start, calibration/validation still running")
self._set_status("Wait for calibration/validation to finish before starting a session.")
return False
if not os.path.exists(CALIBRATION_PATH):
_log(f"[browser_session] no {CALIBRATION_PATH} found — run calibrate.py first")
self._set_status("No calibration.pkl found — run calibrate.py first.")
return False
self.tracking = True
self.session_started_at = time.time()
self.stop_event.clear()
session_dir = os.path.join(SESSIONS_ROOT, datetime.now().strftime("%Y%m%d_%H%M%S"))
os.makedirs(session_dir, exist_ok=True)
self.current_session_dir = session_dir
_log(f"[browser_session] starting tracking thread -> {session_dir}")
self._write_session_meta(session_dir)
self._inject_tracking_overlay()
self._inject_mouse_overlay()
self._open_mouse_log(session_dir)
self._set_mouse_tracking(True)
self._set_session_ui(True)
who = self.active_tracking_subject["name"] if self.active_tracking_subject else "yourself"
self._set_status(f"Recording {who} — gaze + mouse... Press E to stop, H for heatmap, M for mouse panel")
self.thread = threading.Thread(
target=gaze_worker, args=(self.window, self.stop_event, session_dir, self),
daemon=True
)
self.thread.start()
return True
def _write_session_meta(self, session_dir):
"""Ownership is recorded up front, at the moment session_dir is
created — never reconstructed after the fact from which folder a
session happens to be found under. website_url/duration are
deliberately NOT duplicated here: analysis.py already derives
those from gaze_log.jsonl/dom_log.jsonl, and this file only holds
what isn't already derivable — who owns the session and who was
being tracked."""
subject = self.active_tracking_subject
meta = {
"owner_id": self.current_user["id"],
"tracking_mode": "participant" if subject else "self",
"participant_id": subject["id"] if subject else None,
"participant_name": subject["name"] if subject else None,
"started_at": datetime.now().isoformat(),
}
try:
with open(os.path.join(session_dir, "session_meta.json"), "w", encoding="utf-8") as f:
json.dump(meta, f, indent=2)
except Exception as e:
_log(f"[browser_session] could not write session_meta.json: {e}")
if subject:
try:
participants.touch_session_stats(CURRENT_USER_DIR, subject["id"])
except Exception as e:
_log(f"[browser_session] could not update participant stats: {e}")
@_require_token
def stop_tracking(self):
_log("[browser_session] stop_tracking() called from JS")
if not self.tracking:
_log("[browser_session] not currently tracking, ignoring")
return False
self._set_status("Wrapping up your session...")
self._set_mouse_tracking(False)
self._set_session_ui(False)
self.session_started_at = None
self.stop_event.set()
# Give the page a moment to flush its last batch of mouse data over
# log_mouse_data() before the file handle is closed.
if self.mouse_close_timer:
self.mouse_close_timer.cancel()
self.mouse_close_timer = threading.Timer(2.5, self._close_mouse_log)
self.mouse_close_timer.daemon = True
self.mouse_close_timer.start()
return True
@_require_token
def quit_app(self):
# X is an emergency kill switch, not a graceful shutdown — the user
# wants the terminal process gone immediately, camera/threads and all.
_log("[browser_session] quit_app() called from JS -- terminating process")
os._exit(0)
@_require_token
def go_home(self):
# A page loaded over http(s) can't script-navigate itself to a
# file:// URL — Chromium/WebView2 silently blocks it (no exception,
# no navigation), which is what made the toolbar's Home button a
# no-op via location.href. window.load_url() is a native host call,
# not page script, so it isn't subject to that restriction — same
# mechanism the report page's own navigation already relies on.
_log("[browser_session] go_home() called from JS")
try:
# _write_landing_page() writes to the same fixed path every
# time and returns the same URL — but LANDING_URL itself was
# only ever computed once, at app startup, so the "Recent
# Sessions" list it baked in went stale after the first session
# of the run. Rebuild it fresh right before navigating so Home
# always reflects whatever sessions exist right now.
fresh_url = _write_landing_page()
self.window.load_url(fresh_url)
except Exception as e:
_log(f"[browser_session] go_home failed: {e}")
return True
# -- calibration / validation ------------------------------------------
# calibrate.py and validate.py are unmodified, standalone OpenCV/pyautogui
# scripts (their own fullscreen window, their own event loop) — they were
# never meant to share a process with pywebview's GUI loop. Launching them
# as a subprocess reuses them exactly as-is instead of rewriting their
# display logic into the browser.
def _external_proc_running(self):
running = lambda p: p is not None and p.poll() is None
return running(self.calib_proc) or running(self.validate_proc)
def _subprocess_user_env(self):
"""calibrate.py/validate.py run as separate processes and compute
their own DATA_DIR independently (see their own module docstrings)
— this env var is the only channel that tells them which subject's
folder to read/write, and which per-subject path fine-tuning
should save its adapted model to instead of overwriting the
shared bundled ONNX everyone's inference loads. Deliberately
CURRENT_SUBJECT_DIR, not CURRENT_USER_DIR: calibrating while
"Rahul" is the active tracking subject must calibrate Rahul, not
the signed-in owner. Absent (dev/standalone `python calibrate.py`),
both scripts fall back to today's exact behavior — see their own
INSIGHTUX_USER_DATA_DIR checks."""
env = dict(os.environ)
if CURRENT_SUBJECT_DIR:
env["INSIGHTUX_USER_DATA_DIR"] = CURRENT_SUBJECT_DIR
env["INSIGHTUX_USER_ONNX_OUT"] = os.path.join(CURRENT_SUBJECT_DIR, "gaze_cnn_v4_finetuned.onnx")
return env
@_require_token
def start_calibration(self):
_log("[browser_session] start_calibration() called from JS")
if self.current_user is None:
self._set_status("Sign in to a profile before calibrating.")
return False
if self.tracking:
self._set_status("Stop the current session (E) before calibrating.")
return False
if self._external_proc_running():
self._set_status("Calibration/validation is already running in another window.")
return False
try:
self.calib_proc = subprocess.Popen(
_relaunch_args("calibrate"), cwd=BASE_DIR, env=self._subprocess_user_env()
)
except Exception as e:
_log(f"[browser_session] failed to launch calibrate.py: {e}")
self._set_status("Could not launch calibration — see terminal for details.")
return False
self._set_status("Calibration launched in a separate window — follow the dots there.")
threading.Thread(
target=self._watch_external_proc, args=(self.calib_proc, "Calibration"), daemon=True
).start()
return True
@_require_token
def run_validation(self):
_log("[browser_session] run_validation() called from JS")
if self.current_user is None:
self._set_status("Sign in to a profile before validating.")
return False
if self.tracking:
self._set_status("Stop the current session (E) before validating.")
return False
if self._external_proc_running():
self._set_status("Calibration/validation is already running in another window.")
return False
if not os.path.exists(CALIBRATION_PATH):
self._set_status("No calibration.pkl found — run Calibrate first.")
return False
try:
self.validate_proc = subprocess.Popen(
_relaunch_args("validate"), cwd=BASE_DIR, env=self._subprocess_user_env()
)
except Exception as e:
_log(f"[browser_session] failed to launch validate.py: {e}")
self._set_status("Could not launch validation — see terminal for details.")
return False
self._set_status("Validation launched in a separate window — look at each dot.")
threading.Thread(
target=self._watch_external_proc, args=(self.validate_proc, "Validation"), daemon=True
).start()
return True
def _watch_external_proc(self, proc, label):
proc.wait()
_log(f"[browser_session] {label} process exited (code {proc.returncode})")
if label == "Calibration":
self._set_status("Calibration finished — check the terminal for the quality readout, then press S or Start Session.")
else:
self._set_status("Validation finished — check the terminal for the accuracy numbers.")
def _set_session_ui(self, is_tracking):
try:
flag = "true" if is_tracking else "false"
# Pass the session's real start time (fixed at start_tracking(),
# unchanged by later re-injections) instead of letting the JS
# stamp its own "now" — this same call fires again on every
# page navigation while tracking is active (on_page_loaded()
# re-injects the whole toolbar), and a fresh JS context has no
# memory of when the session actually began.
started_ms = (
int(self.session_started_at * 1000)
if (is_tracking and self.session_started_at) else "null"
)
self.window.evaluate_js(
f"window.insightuxSetSessionUI && window.insightuxSetSessionUI({flag}, {started_ms})"
)
except Exception:
pass
def _inject_tracking_overlay(self):
try:
self.window.evaluate_js(TRACKING_JS)
except Exception as e:
_log(f"[browser_session] overlay inject failed: {e}")
def _inject_mouse_overlay(self):
try:
self.window.evaluate_js(MOUSE_JS)
except Exception as e:
_log(f"[browser_session] mouse overlay inject failed: {e}")
def _set_mouse_tracking(self, on):
try:
flag = "true" if on else "false"
self.window.evaluate_js(
f"window.insightuxMouseSetTracking && window.insightuxMouseSetTracking({flag})"
)
except Exception:
pass
def _open_mouse_log(self, session_dir):
self._close_mouse_log()
path = os.path.join(session_dir, "mouse_log.jsonl")
try:
self.mouse_log_f = open(path, "w", buffering=1)
self.mouse_log_f.write(json.dumps({"type": "meta", "t": round(time.time(), 4)}) + "\n")
_log(f"[browser_session] writing mouse stream to {path}")
except Exception as e:
_log(f"[browser_session] could not open mouse log: {e}")
self.mouse_log_f = None
def _close_mouse_log(self):
if self.mouse_log_f:
try:
self.mouse_log_f.close()
_log("[browser_session] mouse log closed")
except Exception:
pass
self.mouse_log_f = None
def log_mouse_data(self, payload):
"""Called from the injected Mouse Tracker overlay (MOUSE_JS) roughly
every 2s while tracking, with a batch of trail / heatmap / click /
dwell records — same cadence as the extension's background.js sync
loop, just persisted to mouse_log.jsonl instead of chrome.storage.
Deliberately NOT @_require_token: MOUSE_JS is injected as its own
separate evaluate_js() call (_inject_mouse_overlay()), a different
top-level script from CHROME_JS with its own closure, so it has no
way to see CHROME_JS's __IUX_TOK__ constant without either being
merged into the same script or having the token attached to
`window` — the latter would defeat the whole point, since any
page's own script could then read it too. Accepted gap: this
already no-ops (returns False) whenever self.mouse_log_f is closed,
i.e. outside a session the user themselves started, so the worst
case is a hostile page's script injecting bogus rows into the
mouse log of a session already running — a data-integrity nuisance
confined to that one file, not a way to start recording, read
other sessions, or touch anything else."""
if not self.mouse_log_f:
return False
try:
rec = dict(payload) if isinstance(payload, dict) else {}
rec["type"] = "mouse_batch"
rec["t"] = round(time.time(), 4)
self.mouse_log_f.write(json.dumps(rec) + "\n")
except Exception as e:
_log(f"[browser_session] mouse log write failed: {e}")
return True
def _set_status(self, text):
try:
self.window.evaluate_js(f"window.insightuxSetStatus && window.insightuxSetStatus({json.dumps(text)})")
except Exception:
pass
api = Api()
def _version_tuple(v):
return tuple(int(p) for p in v.split(".") if p.isdigit())
def check_for_update():
"""Runs once in a background thread at startup. Publishing a new release
is still fully manual (bump VERSION here + AppVersion in installer.iss +
version.json, rebuild, push) — this only automates the *checking* side,
per the confirmed scope. No internet / any failure here is silent:
the user just doesn't see an update banner, never an error."""
import urllib.request
try:
with urllib.request.urlopen(VERSION_CHECK_URL, timeout=4) as resp:
data = json.loads(resp.read().decode("utf-8"))
latest = data.get("latest", "")
url = data.get("url", "")
if latest and url and _version_tuple(latest) > _version_tuple(VERSION):
api.update_info = {"version": latest, "url": url}
api._notify_update(latest, url)
_log(f"[browser_session] update available: {VERSION} -> {latest}")
except Exception as e:
_log(f"[browser_session] update check skipped: {e}")
def on_page_loaded(window):
"""Fires on every page load (navigation, back/forward, reload — not just
the first page). The chrome must be re-injected every time since each
navigation is a fresh document. If a session is already running, the
gaze/mouse overlays need the same treatment or navigating mid-session
would silently kill the dot and the mouse tracker on the new page."""
try:
window.evaluate_js(CHROME_JS)
except Exception as e:
_log(f"[browser_session] chrome inject failed (will retry): {e}")
if api.tracking:
api._inject_tracking_overlay()
api._inject_mouse_overlay()
api._set_mouse_tracking(True)
api._set_session_ui(True)
# Same reasoning as the tracking re-sync above: CHROME_JS was just
# re-injected fresh and has no memory of who's signed in, or who the
# active tracking subject was.
if api.current_user:
api._set_profile_ui()
api._set_subject_ui()
# CHROME_JS was just re-injected fresh on this page and has no memory of
# an update found on a previous page — re-tell it if one was found.
if api.update_info:
api._notify_update(api.update_info["version"], api.update_info["url"])
# =============================================================================
# GAZE WORKER — runs in a background thread, started/stopped via Api
# =============================================================================
def gaze_worker(window, stop_event, session_dir, api_ref):
pipeline = InsightUXPipeline(_user_onnx_path(), CALIBRATION_PATH)
face_mesh = create_face_mesh(static_image_mode=False)
cap = cv2.VideoCapture(0)
if not cap.isOpened():
# Checked here, before GazeLogger/dom_f/screenshots create anything
# on disk, rather than after the fact -- start_tracking() can't do
# this check itself since opening the camera is a blocking call and
# has to happen in this background thread, not the UI thread. Before
# this, a busy/absent/permission-denied camera meant cap.read() just
# returned (False, None) on the very first iteration below, the loop
# silently `break`-ed, and the session fell straight through to
# report generation on zero frames -- a confusing empty report with
# no indication anything had gone wrong.
cap.release()
_log("[browser_session] could not open the camera (in use by another app, no camera present, or permission denied)")
api_ref._set_status(
"Could not access the camera. Check that it isn't already in use by "
"another app and that InsightUX has camera permission, then press S to try again."
)
api_ref.tracking = False
api_ref.current_session_dir = None
api_ref._set_mouse_tracking(False)
api_ref._set_session_ui(False)
api_ref._close_mouse_log()
import shutil
shutil.rmtree(session_dir, ignore_errors=True)
return
logger = GazeLogger(SCREEN_W, SCREEN_H, session_dir=session_dir)
dom_path = os.path.join(session_dir, "dom_log.jsonl")
dom_f = open(dom_path, "w", buffering=1)
screens_dir = os.path.join(session_dir, "screenshots")
os.makedirs(screens_dir, exist_ok=True)
shot_count = 0
last_shot_scroll = None
last_shot_time = 0.0
SCROLL_SHOT_THRESHOLD = 60 # px scrolled before a new snapshot is worth taking
MAX_SHOT_INTERVAL = 2.5 # seconds — take one anyway if you just sit still
def maybe_capture_screenshot(scroll_y):
"""Grab a full-screen shot when the page has scrolled enough, or
periodically even if it hasn't — this is what lets the report show
the heatmap ON TOP of the actual page instead of floating in a void."""
nonlocal shot_count, last_shot_scroll, last_shot_time
now = time.time()
scrolled_enough = last_shot_scroll is None or abs(scroll_y - last_shot_scroll) >= SCROLL_SHOT_THRESHOLD
time_elapsed = (now - last_shot_time) >= MAX_SHOT_INTERVAL
if not (scrolled_enough or time_elapsed):
return None
try:
try:
window.evaluate_js("window.__insightuxSetCaptureMode && window.__insightuxSetCaptureMode(true)")
except Exception:
pass
time.sleep(0.05) # let the hide actually repaint before the OS-level grab
try:
img = pyautogui.screenshot()
finally:
try:
window.evaluate_js("window.__insightuxSetCaptureMode && window.__insightuxSetCaptureMode(false)")
except Exception:
pass
# Force to the same pixel space as gaze coordinates (SCREEN_W x
# SCREEN_H) — Windows DPI scaling can otherwise return a
# screenshot at a different resolution than pyautogui.size(),
# which would silently misalign every heatmap point.
if img.size != (SCREEN_W, SCREEN_H):
img = img.resize((SCREEN_W, SCREEN_H))
shot_name = f"shot_{shot_count:04d}.png"
img.save(os.path.join(screens_dir, shot_name))
shot_count += 1
last_shot_scroll = scroll_y
last_shot_time = now
return f"screenshots/{shot_name}"
except Exception as e:
_log(f"[browser_session] screenshot capture failed: {e}")
return None
_log(f"[browser_session] tracking started -> {session_dir}")
cam_matrix = None
last_x = SCREEN_W / 2
last_y = SCREEN_H / 2
fil_x = OneEuroFilter(mincutoff=EURO_MINCUTOFF, beta=EURO_BETA)
fil_y = OneEuroFilter(mincutoff=EURO_MINCUTOFF, beta=EURO_BETA)
angle_smoother = GazeAngleSmoother(window=ANGLE_SMOOTH_WINDOW)
sm_pitch = sm_yaw = sm_roll = None
no_face_count = 0
frame_i = 0
while not stop_event.is_set():
ret, frame = cap.read()
if not ret:
break
if cam_matrix is None:
cam_matrix = estimate_camera_matrix(frame.shape)
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
results = face_mesh.process(rgb)
if not results.multi_face_landmarks:
no_face_count += 1
if no_face_count >= NO_FACE_RESET:
last_x, last_y = SCREEN_W / 2, SCREEN_H / 2
fil_x.reset(); fil_y.reset()
angle_smoother.reset()
sm_pitch = sm_yaw = sm_roll = None
no_face_count = 0
continue
no_face_count = 0
lms = results.multi_face_landmarks[0].landmark
head_pose = estimate_head_pose(lms, frame.shape, cam_matrix)
if head_pose is None:
continue
if sm_pitch is None:
sm_pitch, sm_yaw, sm_roll = head_pose.pitch, head_pose.yaw, head_pose.roll
else:
b = POSE_SMOOTH
sm_pitch = b*sm_pitch + (1-b)*head_pose.pitch
sm_yaw = b*sm_yaw + (1-b)*head_pose.yaw
sm_roll = b*sm_roll + (1-b)*head_pose.roll
head_pose = replace(head_pose, pitch=sm_pitch, yaw=sm_yaw, roll=sm_roll)
pose_vec = normalize_pose(sm_pitch, sm_yaw, sm_roll)
# Eye aperture — the vertical cue. Calibration decides whether this or
# the CNN's pitch actually tracks screen-Y, and uses the better one.
ear_now = 0.5 * (compute_ear(lms, LEFT_EAR_INDICES, frame.shape) +
compute_ear(lms, RIGHT_EAR_INDICES, frame.shape))
def get_patch(eye_idx, ear_idx, iris_idx):
s1 = step1_normalize(frame, lms, head_pose, eye_idx, ear_idx, iris_idx)
if not s1.is_open:
return None
if PATCH_SOURCE == "norm":
return s1.norm_crop
ir = compute_iris_radius(lms, iris_idx, frame.shape)
s2 = step2_illumination(s1, ir)
return s2.blended if s2.is_usable else None
left_patch = get_patch(LEFT_EYE_INDICES, LEFT_EAR_INDICES, LEFT_IRIS_INDICES)
right_patch = get_patch(RIGHT_EYE_INDICES, RIGHT_EAR_INDICES, RIGHT_IRIS_INDICES)
if left_patch is None and right_patch is None:
continue
if left_patch is None: left_patch = right_patch
if right_patch is None: right_patch = left_patch
_, _, raw_pitch, raw_yaw = pipeline.predict_gaze_vector(left_patch, pose_vec, right_patch)
pitch = compensate_pitch(raw_pitch, sm_pitch)
yaw = compensate_yaw(raw_yaw, sm_yaw)
# Smooth the ANGLES before the RBF sees them. The RBF amplifies input
# noise (steep gradient along the low-variance pitch axis), so noise
# must be removed here — smoothing the screen coords afterwards cannot
# undo amplification that already happened through a nonlinear map.
pitch, yaw, ear_s = angle_smoother(pitch, yaw, ear_now)
sx, sy = pipeline.calibration.predict(pitch, yaw, ear_s)
sx = max(0.0, min(sx, SCREEN_W))
sy = max(0.0, min(sy, SCREEN_H))
jump = np.hypot(sx - last_x, sy - last_y)
if jump > MAX_JUMP:
sx = last_x + (sx - last_x) * 0.3
sy = last_y + (sy - last_y) * 0.3
last_x, last_y = sx, sy
logger.log(sx, sy)
now = time.time()
fx = fil_x(sx / SCREEN_W, now)
fy = fil_y(sy / SCREEN_H, now)
try:
window.evaluate_js(f"window.insightuxUpdate({fx:.5f},{fy:.5f})")
except Exception:
pass
frame_i += 1
if frame_i % DOM_EVERY == 0:
try:
raw = window.evaluate_js("window.insightuxAOIs()")
if raw:
rec = json.loads(raw)
rec["type"] = "dom"
rec["t"] = round(time.time(), 4)
shot_ref = maybe_capture_screenshot(rec.get("scrollY", 0))
if shot_ref:
rec["screenshot"] = shot_ref
dom_f.write(json.dumps(rec) + "\n")
except Exception:
pass
time.sleep(0.005)
cap.release()
logger.close()
dom_f.close()
_log(f"[browser_session] session saved in {session_dir}")
# generate report and hand control back
try:
report_path = generate_report(session_dir)
api_ref.tracking = False
api_ref.current_session_dir = None
window.load_url("file://" + report_path)
_log(f"[browser_session] report ready -> {report_path}")
except Exception as e:
_log(f"[browser_session] report generation failed: {e}")
api_ref.tracking = False
api_ref.current_session_dir = None
# =============================================================================
# MAIN
# =============================================================================
def _go_fullscreen(window):
# Small delay so WebView2 fully initializes and grabs keyboard focus
# BEFORE going fullscreen. Passing fullscreen=True straight to
# create_window is what causes the "can't type" issue and the
# AccessibilityObject.Bounds recursion crash on Windows — both trace
# back to the WinForms host not finishing its focus/accessibility
# setup before the fullscreen transition happens.
time.sleep(0.4)
try:
window.toggle_fullscreen()
except Exception as e:
_log(f"[browser_session] fullscreen toggle failed: {e}")
if __name__ == "__main__":
# A packaged build is one exe with no separate calibrate.py/validate.py
# files to shell out to — --mode makes it a single self-dispatching
# entry point instead. See _relaunch_args() above for the launch side.
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--mode", choices=["browser", "calibrate", "validate"], default="browser")
args = parser.parse_args()
if args.mode == "calibrate":
import calibrate
calibrate.main()
elif args.mode == "validate":
import validate
validate.main()
else:
# Opens on the login/profile picker, not the landing page — no
# login state is persisted across restarts, so a password is
# always required before any profile's sessions/calibration/
# reports become reachable. A fresh listing of profiles is
# written right before opening in case a previous run created one.
window = webview.create_window(
"InsightUX — Eye-Tracking Research Browser", _write_login_page(),
width=SCREEN_W, height=SCREEN_H, js_api=api
)
api.window = window
window.events.loaded += lambda: on_page_loaded(window)
window.events.shown += lambda: threading.Thread(
target=_go_fullscreen, args=(window,), daemon=True
).start()
threading.Thread(target=check_for_update, daemon=True).start()
# debug=True enables right-click > Inspect (DevTools) so you can see
# the [insightux] console.log diagnostics from CHROME_JS directly —
# keyboard handling runs entirely in the browser, so JS-side issues
# never show up in this terminal, only in DevTools' Console tab.
webview.start(debug=True)