Capstone project. I'm ngl it's vibe-coded and it's only here so I can mess around with it
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add guided calibration and screen-level debrief heatmaps

- Rework the UI for calibration, status, and test debrief
- Capture per-screen screenshots and render heatmaps in the gallery
- Add long-page demo app and debug controls for test flow

+2191 -1095
+12
.uxet-server.log
··· 1 + 127.0.0.1 - - [23/Mar/2026 23:27:04] "GET / HTTP/1.1" 200 - 2 + 127.0.0.1 - - [23/Mar/2026 23:27:04] "GET /index.css HTTP/1.1" 200 - 3 + 127.0.0.1 - - [23/Mar/2026 23:27:04] "GET /js/main.js HTTP/1.1" 200 - 4 + 127.0.0.1 - - [23/Mar/2026 23:27:04] "GET /js/session.js HTTP/1.1" 200 - 5 + 127.0.0.1 - - [23/Mar/2026 23:27:04] "GET /js/tracker.js HTTP/1.1" 200 - 6 + 127.0.0.1 - - [23/Mar/2026 23:27:04] "GET /js/gazeTracker.js HTTP/1.1" 200 - 7 + 127.0.0.1 - - [23/Mar/2026 23:27:04] "GET /js/iframeBridge.js HTTP/1.1" 200 - 8 + 127.0.0.1 - - [23/Mar/2026 23:27:04] "GET /js/calibration.js HTTP/1.1" 200 - 9 + 127.0.0.1 - - [23/Mar/2026 23:27:04] "GET /js/winConditions.js HTTP/1.1" 200 - 10 + 127.0.0.1 - - [23/Mar/2026 23:27:04] "GET /js/debriefRenderer.js HTTP/1.1" 200 - 11 + 127.0.0.1 - - [23/Mar/2026 23:27:05] code 404, message File not found 12 + 127.0.0.1 - - [23/Mar/2026 23:27:05] "GET /favicon.ico HTTP/1.1" 404 -
+1
.uxet-server.pid
··· 1 + 46419
+288 -113
index.css
··· 1 - /* UXET Minimal Styles */ 1 + :root { 2 + --bg: #f2efe8; 3 + --panel: rgba(255, 251, 245, 0.92); 4 + --panel-solid: #fffaf3; 5 + --ink: #1b1a17; 6 + --muted: #6b675f; 7 + --line: rgba(27, 26, 23, 0.12); 8 + --accent: #a33b24; 9 + --accent-strong: #7e2817; 10 + --ok: #276749; 11 + --warn: #b45309; 12 + --shadow: 0 24px 60px rgba(41, 31, 22, 0.12); 13 + } 14 + 15 + * { 16 + box-sizing: border-box; 17 + } 2 18 3 19 body { 4 - font-family: system-ui, -apple-system, sans-serif; 5 20 margin: 0; 6 - padding: 20px; 7 - background: #fff; 8 - color: #000; 9 - line-height: 1.5; 21 + min-height: 100vh; 22 + background: 23 + radial-gradient(circle at top left, rgba(222, 177, 120, 0.22), transparent 28%), 24 + radial-gradient(circle at bottom right, rgba(163, 59, 36, 0.16), transparent 26%), 25 + linear-gradient(180deg, #f8f4ec 0%, #efe8dd 100%); 26 + color: var(--ink); 27 + font-family: Georgia, 'Times New Roman', serif; 28 + line-height: 1.45; 29 + } 30 + 31 + button, 32 + select, 33 + input, 34 + textarea { 35 + font: inherit; 10 36 } 11 37 12 - /* Base Layout */ 13 38 #app-container { 14 - display: flex; 15 - flex-direction: column; 16 - gap: 15px; 17 - height: calc(100vh - 40px); 39 + min-height: 100vh; 40 + padding: 24px; 41 + display: grid; 42 + grid-template-rows: auto auto 1fr; 43 + gap: 16px; 18 44 } 19 45 20 46 #controls, 21 - #status-bar { 22 - padding: 10px; 23 - border: 1px solid #ccc; 24 - background: #f9f9f9; 47 + #status-bar, 48 + .overlay-card, 49 + .screen-card { 50 + border: 1px solid var(--line); 51 + background: var(--panel); 52 + backdrop-filter: blur(12px); 53 + box-shadow: var(--shadow); 25 54 } 26 55 27 56 #controls { 57 + padding: 20px; 58 + border-radius: 22px; 59 + } 60 + 61 + .brand-block h1, 62 + .panel-header h2 { 63 + margin: 0; 64 + font-size: clamp(1.8rem, 2vw, 2.5rem); 65 + letter-spacing: -0.03em; 66 + } 67 + 68 + .brand-block p, 69 + .panel-header p, 70 + #screen-gallery-label { 71 + margin: 6px 0 0; 72 + color: var(--muted); 73 + } 74 + 75 + .control-row { 76 + margin-top: 16px; 77 + display: flex; 78 + flex-wrap: wrap; 79 + gap: 16px; 80 + align-items: end; 81 + justify-content: space-between; 82 + } 83 + 84 + .field { 85 + display: grid; 86 + gap: 6px; 87 + min-width: min(520px, 100%); 88 + } 89 + 90 + .field span, 91 + .label { 92 + font-size: 0.78rem; 93 + text-transform: uppercase; 94 + letter-spacing: 0.08em; 95 + color: var(--muted); 96 + } 97 + 98 + select, 99 + button { 100 + border-radius: 14px; 101 + border: 1px solid var(--line); 102 + padding: 12px 16px; 103 + background: var(--panel-solid); 104 + color: var(--ink); 105 + } 106 + 107 + button { 108 + cursor: pointer; 109 + transition: transform 0.16s ease, background 0.16s ease, border-color 0.16s ease; 110 + } 111 + 112 + button:hover:not(:disabled) { 113 + transform: translateY(-1px); 114 + border-color: rgba(163, 59, 36, 0.35); 115 + } 116 + 117 + button:disabled { 118 + opacity: 0.45; 119 + cursor: not-allowed; 120 + } 121 + 122 + #load-app-btn, 123 + #start-test-btn, 124 + #export-btn { 125 + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); 126 + color: #fff8f3; 127 + border: none; 128 + } 129 + 130 + .primary-actions, 131 + .debug-shell { 28 132 display: flex; 29 133 align-items: center; 30 - gap: 15px; 134 + gap: 10px; 135 + flex-wrap: wrap; 31 136 } 32 137 33 - #controls h1 { 34 - margin: 0; 35 - font-size: 1.4rem; 138 + .debug-toggle { 139 + background: transparent; 36 140 } 37 141 38 - #app-select { 39 - padding: 4px; 40 - font-size: 1rem; 142 + #debug-panel { 143 + display: flex; 144 + gap: 10px; 145 + flex-wrap: wrap; 146 + } 147 + 148 + #status-bar { 149 + border-radius: 18px; 150 + padding: 14px 18px; 151 + display: grid; 152 + grid-template-columns: repeat(4, minmax(0, 1fr)); 153 + gap: 12px; 154 + } 155 + 156 + #status-bar > div { 157 + display: grid; 158 + gap: 4px; 41 159 } 42 160 43 161 #main-content { 44 - display: flex; 45 - flex-direction: column; 46 - border: 1px solid #ccc; 47 - flex-grow: 1; 48 162 position: relative; 163 + border-radius: 24px; 49 164 overflow: hidden; 165 + border: 1px solid var(--line); 166 + background: rgba(255, 249, 240, 0.72); 167 + min-height: 65vh; 50 168 } 51 169 52 - /* Helper Classes */ 53 - .hidden { 54 - display: none !important; 170 + #iframe-placeholder, 171 + #test-iframe, 172 + #calibration-screen, 173 + #debrief-screen { 174 + position: absolute; 175 + inset: 0; 55 176 } 56 177 57 - /* Iframe Area */ 58 178 #iframe-placeholder { 59 - padding: 40px; 60 - text-align: center; 61 - font-weight: bold; 62 - color: #666; 179 + display: grid; 180 + place-items: center; 181 + color: var(--muted); 182 + font-size: 1.1rem; 183 + padding: 32px; 63 184 } 64 185 65 186 #test-iframe { ··· 69 190 background: white; 70 191 } 71 192 72 - /* Overlays (Calibration & Debrief) */ 73 193 #calibration-screen, 74 194 #debrief-screen { 75 - position: absolute; 76 - inset: 0; 77 - background: #fff; 78 - padding: 20px; 79 - overflow-y: auto; 80 - z-index: 10; 195 + z-index: 2; 81 196 } 82 197 83 198 #calibration-screen { 84 - display: flex; 85 - flex-direction: column; 199 + background: rgba(20, 17, 14, 0.16); 200 + } 201 + 202 + .overlay-card { 203 + border-radius: 22px; 86 204 } 87 205 88 - #calibration-header { 89 - text-align: center; 90 - margin-bottom: 20px; 91 - z-index: 20; 92 - position: relative; 206 + .calibration-panel, 207 + .debrief-panel { 208 + position: absolute; 209 + top: 20px; 210 + left: 20px; 211 + right: 20px; 212 + padding: 20px; 213 + z-index: 3; 93 214 } 94 215 95 - #calibration-header h2 { 96 - margin-top: 0; 216 + .calibration-stats { 217 + display: grid; 218 + grid-template-columns: repeat(4, minmax(0, 1fr)); 219 + gap: 12px; 220 + margin-top: 18px; 97 221 } 98 222 99 - /* Calibration Grid */ 100 - #calibration-grid { 101 - position: relative; 102 - flex-grow: 1; 103 - min-height: 500px; 223 + .calibration-stats > div, 224 + #stats-container li { 225 + display: grid; 226 + gap: 4px; 104 227 } 105 228 106 - .calibration-point { 229 + #calibration-stage { 107 230 position: absolute; 108 - width: 40px; 109 - height: 40px; 110 - font-size: 16px; 111 - cursor: pointer; 112 - background: #eee; 113 - border: 1px solid #999; 114 - transform: translate(-50%, -50%); 231 + inset: 0; 115 232 } 116 233 117 - .calibration-point.done { 118 - background: #4CAF50; 234 + #calibration-target { 235 + position: absolute; 236 + width: 64px; 237 + height: 64px; 238 + border-radius: 999px; 239 + transform: translate(-50%, -50%); 240 + display: flex; 241 + align-items: center; 242 + justify-content: center; 243 + border: none; 119 244 color: white; 245 + font-size: 1.15rem; 246 + background: radial-gradient(circle at 30% 30%, #ffcf70, #b03922 65%, #7e2817); 247 + box-shadow: 0 18px 36px rgba(126, 40, 23, 0.35); 120 248 } 121 249 122 - /* Absolute Positioning for 9-point grid */ 123 - #cal-pt-1 { 124 - top: 10%; 125 - left: 10%; 250 + #debrief-screen { 251 + overflow: auto; 252 + padding: 20px; 253 + background: rgba(247, 242, 234, 0.95); 126 254 } 127 255 128 - #cal-pt-2 { 129 - top: 10%; 130 - left: 50%; 256 + #stats-container { 257 + margin-top: 18px; 258 + display: grid; 259 + grid-template-columns: repeat(2, minmax(0, 1fr)); 260 + gap: 20px; 131 261 } 132 262 133 - #cal-pt-3 { 134 - top: 10%; 135 - left: 90%; 263 + #stats-container ul { 264 + list-style: none; 265 + margin: 0; 266 + padding: 0; 267 + display: grid; 268 + gap: 10px; 136 269 } 137 270 138 - #cal-pt-4 { 139 - top: 50%; 140 - left: 10%; 271 + #stats-container li span { 272 + font-size: 0.85rem; 273 + color: var(--muted); 141 274 } 142 275 143 - #cal-pt-5 { 144 - top: 50%; 145 - left: 50%; 276 + #screen-gallery { 277 + margin-top: 196px; 278 + display: grid; 279 + gap: 18px; 146 280 } 147 281 148 - #cal-pt-6 { 149 - top: 50%; 150 - left: 90%; 282 + .screen-card { 283 + border-radius: 22px; 284 + padding: 18px; 151 285 } 152 286 153 - #cal-pt-7 { 154 - top: 90%; 155 - left: 10%; 287 + .screen-card-header { 288 + display: flex; 289 + justify-content: space-between; 290 + gap: 16px; 291 + align-items: start; 292 + margin-bottom: 14px; 156 293 } 157 294 158 - #cal-pt-8 { 159 - top: 90%; 160 - left: 50%; 295 + .screen-card-header h4 { 296 + margin: 0; 297 + font-size: 1.15rem; 161 298 } 162 299 163 - #cal-pt-9 { 164 - top: 90%; 165 - left: 90%; 300 + .screen-card-header p, 301 + .screen-card-stats span { 302 + margin: 4px 0 0; 303 + color: var(--muted); 304 + font-size: 0.9rem; 166 305 } 167 306 168 - /* Stats & Debrief */ 169 - #stats-container { 170 - display: flex; 171 - gap: 40px; 172 - margin-bottom: 20px; 307 + .screen-card-stats { 308 + display: grid; 309 + justify-items: end; 310 + gap: 4px; 173 311 } 174 312 175 - #stats-container ul { 176 - list-style: none; 177 - padding: 0; 178 - margin: 0; 313 + .screen-card-canvas-wrap { 314 + border-radius: 16px; 315 + overflow: hidden; 316 + border: 1px solid var(--line); 317 + background: #fff; 179 318 } 180 319 181 - #stats-container li { 182 - margin-bottom: 5px; 320 + .screen-heatmap-canvas { 321 + display: block; 322 + width: 100%; 323 + height: auto; 183 324 } 184 325 185 - canvas#heatmap-canvas { 186 - border: 1px solid #ccc; 187 - max-width: 100%; 188 - width: 100%; 189 - height: 400px; 190 - background: #fafafa; 191 - display: block; 192 - margin-bottom: 10px; 193 - } 326 + .screen-card-fallback { 327 + min-height: 320px; 328 + display: grid; 329 + place-items: center; 330 + padding: 24px; 331 + color: var(--muted); 332 + text-align: center; 333 + } 334 + 335 + .hidden { 336 + display: none !important; 337 + } 338 + 339 + @media (max-width: 900px) { 340 + #app-container { 341 + padding: 16px; 342 + } 343 + 344 + #status-bar, 345 + .calibration-stats, 346 + #stats-container { 347 + grid-template-columns: 1fr; 348 + } 349 + 350 + .calibration-panel, 351 + .debrief-panel { 352 + left: 12px; 353 + right: 12px; 354 + top: 12px; 355 + } 356 + 357 + #screen-gallery { 358 + margin-top: 290px; 359 + } 360 + 361 + .screen-card-header { 362 + flex-direction: column; 363 + } 364 + 365 + .screen-card-stats { 366 + justify-items: start; 367 + } 368 + }
+90 -63
index.html
··· 4 4 <head> 5 5 <meta charset="UTF-8"> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 - <title>UXET - Testing</title> 7 + <title>UXET</title> 8 8 <link rel="stylesheet" href="index.css"> 9 9 <script src="https://cdn.jsdelivr.net/npm/webgazer@3.4.0/dist/webgazer.js" crossorigin="anonymous"></script> 10 + <script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js" crossorigin="anonymous"></script> 10 11 </head> 11 12 12 13 <body> 13 14 <div id="app-container"> 14 15 <header id="controls"> 15 - <h1>UXET</h1> 16 - <select id="app-select"> 17 - <option value="">Select an app...</option> 18 - <option value="testable-apps/shop-app/index.html" data-task="Find and purchase a blue t-shirt" 19 - data-win="selector:.checkout-success.active">ShopEasy Store</option> 20 - <option value="testable-apps/example-app/index.html" 21 - data-task="Fill out the contact form with your details" data-win="text:Form submitted successfully"> 22 - Example Form App</option> 23 - </select> 24 - <button id="load-app-btn">Load App</button> 25 - <button id="reset-btn">Reset</button> 26 - <span id="calibration-controls" class="hidden"> 27 - <button id="debug-skip-calibration">Skip Calibration</button> 28 - <button id="start-test-btn" class="hidden">Start Test</button> 29 - </span> 30 - </header> 16 + <div class="brand-block"> 17 + <h1>UXET</h1> 18 + <p>Calibrate, record, and review real page-level gaze coverage.</p> 19 + </div> 31 20 32 - <div id="status-bar"> 33 - <span>Status: <strong id="session-status">Idle</strong></span> | 34 - <span>Time: <strong id="session-timer">00:00:00</strong></span> | 35 - <span>Task: <strong id="current-task-text">None</strong></span> 36 - </div> 21 + <div class="control-row"> 22 + <label class="field"> 23 + <span>App Under Test</span> 24 + <select id="app-select"> 25 + <option value="">Select an app...</option> 26 + <option value="testable-apps/shop-app/index.html" data-task="Find and purchase a blue t-shirt" 27 + data-win="selector:.checkout-success.active">ShopEasy Store</option> 28 + <option value="testable-apps/example-app/index.html" 29 + data-task="Fill out the contact form with your details" data-win="text:Form submitted successfully"> 30 + Example Form App 31 + </option> 32 + <option value="testable-apps/long-page-app/index.html" 33 + data-task="Review the comparison sections and subscribe at the bottom of the page" 34 + data-win="selector:#success-banner">Long Page Demo</option> 35 + </select> 36 + </label> 37 37 38 - <div id="main-content"> 39 - <div id="iframe-placeholder">Select an app and click "Load App" to begin.</div> 40 - 41 - <div id="calibration-screen" class="hidden"> 42 - <div id="calibration-header"> 43 - <h2>Calibration</h2> 44 - <p>Click each point 5 times while looking directly at it. (<span id="calibration-progress-text">0 of 45 - 9</span> points calibrated)</p> 38 + <div class="primary-actions"> 39 + <button id="load-app-btn">Load App</button> 40 + <button id="start-test-btn" class="hidden">Start Test</button> 41 + <button id="export-btn" disabled>Export Data</button> 42 + <button id="reset-btn">Reset</button> 46 43 </div> 47 - <div id="calibration-grid"> 48 - <button class="calibration-point" id="cal-pt-1">1</button> 49 - <button class="calibration-point" id="cal-pt-2">2</button> 50 - <button class="calibration-point" id="cal-pt-3">3</button> 51 - <button class="calibration-point" id="cal-pt-4">4</button> 52 - <button class="calibration-point" id="cal-pt-5">5</button> 53 - <button class="calibration-point" id="cal-pt-6">6</button> 54 - <button class="calibration-point" id="cal-pt-7">7</button> 55 - <button class="calibration-point" id="cal-pt-8">8</button> 56 - <button class="calibration-point" id="cal-pt-9">9</button> 44 + </div> 45 + 46 + <div class="debug-shell"> 47 + <button id="debug-toggle-btn" class="debug-toggle" type="button">Debug Controls</button> 48 + <div id="debug-panel" class="hidden"> 49 + <button id="debug-skip-calibration" type="button">Skip Calibration</button> 50 + <button id="debug-end-test" type="button" disabled>End Test</button> 57 51 </div> 58 52 </div> 53 + </header> 59 54 55 + <section id="status-bar"> 56 + <div><span class="label">State</span><strong id="session-status">idle</strong></div> 57 + <div><span class="label">Time</span><strong id="session-timer">00:00:00</strong></div> 58 + <div><span class="label">Task</span><strong id="current-task-text">None</strong></div> 59 + <div><span class="label">Message</span><strong id="session-message">Select an app to begin.</strong></div> 60 + </section> 61 + 62 + <main id="main-content"> 63 + <div id="iframe-placeholder">Select an app and load it to begin calibration.</div> 60 64 <iframe id="test-iframe" class="hidden" title="Test Application"></iframe> 61 65 62 - <div id="debrief-screen" class="hidden"> 63 - <h2>Test Complete</h2> 64 - <div id="stats-container"> 65 - <ul> 66 - <li><b>Time:</b> <span id="debrief-time">00:00</span></li> 67 - <li><b>Clicks:</b> <span id="debrief-clicks">0</span></li> 68 - <li><b>Keys:</b> <span id="debrief-keys">0</span></li> 69 - <li><b>Distance:</b> <span id="debrief-distance">0</span> px</li> 70 - <li><b>Scrolls:</b> <span id="debrief-scrolls">0</span></li> 71 - <li><b>Avg Speed:</b> <span id="debrief-velocity">0</span> px/s</li> 72 - </ul> 73 - <ul> 74 - <li><b>Gaze Points:</b> <span id="debrief-gaze-points">0</span></li> 75 - <li><b>Fixations:</b> <span id="debrief-fixations">0</span></li> 76 - <li><b>Avg Fixation:</b> <span id="debrief-fixation-duration">0</span> ms</li> 77 - </ul> 66 + <section id="calibration-screen" class="hidden"> 67 + <div class="overlay-card calibration-panel"> 68 + <div class="panel-header"> 69 + <h2>Guided Calibration</h2> 70 + <p id="calibration-instruction">Look directly at the target and click it three times.</p> 71 + </div> 72 + 73 + <div class="calibration-stats"> 74 + <div><span class="label">Point</span><strong id="calibration-progress">1 / 9</strong></div> 75 + <div><span class="label">Clicks</span><strong id="calibration-clicks">0 / 3</strong></div> 76 + <div><span class="label">Quality</span><strong id="calibration-quality">Pending</strong></div> 77 + <div><span class="label">Status</span><strong id="calibration-feedback">Waiting for first point</strong></div> 78 + </div> 78 79 </div> 79 80 80 - <h3>Heatmap</h3> 81 - <canvas id="heatmap-canvas"></canvas> 82 - <p id="heatmap-label">Gaze density overlay</p> 81 + <div id="calibration-stage"> 82 + <button id="calibration-target" type="button">1</button> 83 + </div> 84 + </section> 85 + 86 + <section id="debrief-screen" class="hidden"> 87 + <div class="overlay-card debrief-panel"> 88 + <div class="panel-header"> 89 + <h2>Test Debrief</h2> 90 + <p id="screen-gallery-label">Heatmaps will appear here after a test completes.</p> 91 + </div> 83 92 84 - <button id="export-btn">Export Data</button> 85 - </div> 86 - </div> 93 + <div id="stats-container"> 94 + <ul> 95 + <li><span>Time</span><strong id="debrief-time">00:00:00</strong></li> 96 + <li><span>Clicks</span><strong id="debrief-clicks">0</strong></li> 97 + <li><span>Keys</span><strong id="debrief-keys">0</strong></li> 98 + <li><span>Mouse Distance</span><strong id="debrief-distance">0</strong></li> 99 + <li><span>Scroll Events</span><strong id="debrief-scrolls">0</strong></li> 100 + <li><span>Avg Velocity</span><strong id="debrief-velocity">0</strong></li> 101 + </ul> 102 + <ul> 103 + <li><span>Gaze Points</span><strong id="debrief-gaze-points">0</strong></li> 104 + <li><span>Fixations</span><strong id="debrief-fixations">0</strong></li> 105 + <li><span>Avg Fixation</span><strong id="debrief-fixation-duration">0</strong></li> 106 + </ul> 107 + </div> 108 + </div> 109 + 110 + <div id="screen-gallery"></div> 111 + </section> 112 + </main> 87 113 </div> 114 + 88 115 <script type="module" src="js/main.js"></script> 89 116 </body> 90 117 91 - </html> 118 + </html>
+187
js/calibration.js
··· 1 + const CALIBRATION_SEQUENCE = [ 2 + { id: 'center', x: 50, y: 50 }, 3 + { id: 'top-left', x: 12, y: 12 }, 4 + { id: 'top-right', x: 88, y: 12 }, 5 + { id: 'bottom-left', x: 12, y: 88 }, 6 + { id: 'bottom-right', x: 88, y: 88 }, 7 + { id: 'top', x: 50, y: 12 }, 8 + { id: 'left', x: 12, y: 50 }, 9 + { id: 'right', x: 88, y: 50 }, 10 + { id: 'bottom', x: 50, y: 88 } 11 + ]; 12 + 13 + /** 14 + * @typedef {Object} CalibrationResult 15 + * @property {boolean} passed 16 + * @property {number} averageError 17 + * @property {Array<{id:string,error:number,attempts:number,passed:boolean}>} points 18 + */ 19 + 20 + export class CalibrationController { 21 + constructor({ gazeTracker, elements }) { 22 + this.gazeTracker = gazeTracker; 23 + this.elements = elements; 24 + this.sequence = CALIBRATION_SEQUENCE; 25 + this.clicksPerPoint = 3; 26 + this.sampleWindowMs = 600; 27 + this.maxPointError = 120; 28 + this.maxAverageError = 80; 29 + this.currentIndex = 0; 30 + this.currentClicks = 0; 31 + this.pointResults = []; 32 + this.currentPointStart = 0; 33 + this.onReady = null; 34 + this.onStateChange = null; 35 + this.onPass = null; 36 + } 37 + 38 + start() { 39 + this.currentIndex = 0; 40 + this.currentClicks = 0; 41 + this.pointResults = this.sequence.map((point) => ({ 42 + id: point.id, 43 + error: null, 44 + attempts: 0, 45 + passed: false 46 + })); 47 + this.currentPointStart = Date.now(); 48 + this.elements.calibrationQuality.textContent = 'Pending'; 49 + this.elements.calibrationFeedback.textContent = 'Waiting for first point'; 50 + this.updateUi(); 51 + this.emitState(); 52 + } 53 + 54 + async handleTargetClick() { 55 + const point = this.sequence[this.currentIndex]; 56 + if (!point) { 57 + return; 58 + } 59 + 60 + this.currentClicks += 1; 61 + this.updateUi(); 62 + 63 + if (this.currentClicks < this.clicksPerPoint) { 64 + return; 65 + } 66 + 67 + const pointResult = this.pointResults[this.currentIndex]; 68 + pointResult.attempts += 1; 69 + 70 + this.elements.calibrationFeedback.textContent = 'Scoring point...'; 71 + 72 + await new Promise((resolve) => window.setTimeout(resolve, 220)); 73 + 74 + const target = this.getTargetPixelPosition(point); 75 + const samples = this.gazeTracker.getCalibrationSamples( 76 + this.currentPointStart, 77 + Date.now() + this.sampleWindowMs 78 + ); 79 + const inIframeSamples = samples.filter((sample) => sample.inIframe); 80 + const error = inIframeSamples.length 81 + ? Math.round( 82 + inIframeSamples.reduce((sum, sample) => { 83 + const dx = sample.viewportX - target.x; 84 + const dy = sample.viewportY - target.y; 85 + return sum + Math.hypot(dx, dy); 86 + }, 0) / inIframeSamples.length 87 + ) 88 + : Number.POSITIVE_INFINITY; 89 + 90 + pointResult.error = error; 91 + pointResult.passed = Number.isFinite(error) && error <= this.maxPointError; 92 + 93 + if (!pointResult.passed) { 94 + this.currentClicks = 0; 95 + this.currentPointStart = Date.now(); 96 + this.elements.calibrationFeedback.textContent = 'Needs retry'; 97 + this.elements.calibrationQuality.textContent = this.getQualityLabel(error); 98 + this.updateUi(); 99 + this.emitState(); 100 + return; 101 + } 102 + 103 + this.currentIndex += 1; 104 + this.currentClicks = 0; 105 + this.currentPointStart = Date.now(); 106 + 107 + if (this.currentIndex >= this.sequence.length) { 108 + const result = this.finalize(); 109 + if (this.onPass) { 110 + this.onPass(result); 111 + } 112 + return; 113 + } 114 + 115 + this.elements.calibrationFeedback.textContent = 'Point accepted'; 116 + this.elements.calibrationQuality.textContent = this.getQualityLabel(error); 117 + this.updateUi(); 118 + this.emitState(); 119 + } 120 + 121 + finalize() { 122 + const scoredPoints = this.pointResults.filter((point) => point.passed && Number.isFinite(point.error)); 123 + const averageError = scoredPoints.length 124 + ? Math.round(scoredPoints.reduce((sum, point) => sum + point.error, 0) / scoredPoints.length) 125 + : Number.POSITIVE_INFINITY; 126 + const passed = scoredPoints.length === this.sequence.length && averageError <= this.maxAverageError; 127 + const result = { 128 + passed, 129 + averageError, 130 + points: this.pointResults.map((point) => ({ ...point })) 131 + }; 132 + 133 + this.elements.calibrationFeedback.textContent = passed ? 'Calibration passed' : 'Calibration failed'; 134 + this.elements.calibrationQuality.textContent = this.getQualityLabel(averageError); 135 + this.elements.calibrationTarget.style.display = 'none'; 136 + this.emitState(result); 137 + return result; 138 + } 139 + 140 + getTargetPixelPosition(point) { 141 + const rect = this.elements.calibrationStage.getBoundingClientRect(); 142 + return { 143 + x: rect.left + (rect.width * point.x / 100), 144 + y: rect.top + (rect.height * point.y / 100) 145 + }; 146 + } 147 + 148 + updateUi() { 149 + const point = this.sequence[this.currentIndex]; 150 + const currentNumber = Math.min(this.currentIndex + 1, this.sequence.length); 151 + this.elements.calibrationProgress.textContent = `${currentNumber} / ${this.sequence.length}`; 152 + this.elements.calibrationClicks.textContent = `${this.currentClicks} / ${this.clicksPerPoint}`; 153 + this.elements.calibrationTarget.style.display = point ? 'flex' : 'none'; 154 + 155 + if (point) { 156 + this.elements.calibrationTarget.style.left = `${point.x}%`; 157 + this.elements.calibrationTarget.style.top = `${point.y}%`; 158 + this.elements.calibrationTarget.textContent = String(currentNumber); 159 + this.elements.calibrationInstruction.textContent = `Look directly at point ${currentNumber} and click it ${this.clicksPerPoint} times.`; 160 + } 161 + } 162 + 163 + getQualityLabel(error) { 164 + if (!Number.isFinite(error)) { 165 + return 'Needs retry'; 166 + } 167 + if (error <= 45) { 168 + return 'Excellent'; 169 + } 170 + if (error <= 80) { 171 + return 'Good'; 172 + } 173 + return 'Needs retry'; 174 + } 175 + 176 + emitState(result = null) { 177 + if (this.onStateChange) { 178 + this.onStateChange({ 179 + currentIndex: this.currentIndex, 180 + totalPoints: this.sequence.length, 181 + currentClicks: this.currentClicks, 182 + quality: this.elements.calibrationQuality.textContent, 183 + result 184 + }); 185 + } 186 + } 187 + }
+142
js/debriefRenderer.js
··· 1 + function loadImage(src) { 2 + return new Promise((resolve, reject) => { 3 + const image = new Image(); 4 + image.onload = () => resolve(image); 5 + image.onerror = reject; 6 + image.src = src; 7 + }); 8 + } 9 + 10 + function heatmapColor(t) { 11 + if (t < 0.25) { 12 + const f = t / 0.25; 13 + return [0, Math.round(f * 180), Math.round(200 + f * 55)]; 14 + } 15 + if (t < 0.5) { 16 + const f = (t - 0.25) / 0.25; 17 + return [0, Math.round(180 + f * 75), Math.round(255 - f * 255)]; 18 + } 19 + if (t < 0.75) { 20 + const f = (t - 0.5) / 0.25; 21 + return [Math.round(f * 255), 255, 0]; 22 + } 23 + const f = (t - 0.75) / 0.25; 24 + return [255, Math.round(255 - f * 255), 0]; 25 + } 26 + 27 + async function renderHeatmapCanvas(screen) { 28 + const hasScreenshot = screen.screenshot.status === 'ready' && screen.screenshot.dataUrl; 29 + const width = screen.screenshot.width || screen.document.width || 1200; 30 + const height = screen.screenshot.height || screen.document.height || 800; 31 + const canvas = document.createElement('canvas'); 32 + canvas.width = width; 33 + canvas.height = height; 34 + canvas.className = 'screen-heatmap-canvas'; 35 + 36 + const ctx = canvas.getContext('2d'); 37 + ctx.fillStyle = '#ffffff'; 38 + ctx.fillRect(0, 0, width, height); 39 + 40 + if (hasScreenshot) { 41 + const image = await loadImage(screen.screenshot.dataUrl); 42 + ctx.drawImage(image, 0, 0, width, height); 43 + } 44 + 45 + const heatCanvas = document.createElement('canvas'); 46 + heatCanvas.width = width; 47 + heatCanvas.height = height; 48 + const heatCtx = heatCanvas.getContext('2d'); 49 + const radius = Math.max(36, Math.min(width, height) * 0.045); 50 + 51 + screen.gazePoints 52 + .filter((point) => point.inIframe) 53 + .filter((point) => point.docX >= 0 && point.docX <= width && point.docY >= 0 && point.docY <= height) 54 + .forEach((point) => { 55 + const gradient = heatCtx.createRadialGradient(point.docX, point.docY, 0, point.docX, point.docY, radius); 56 + gradient.addColorStop(0, 'rgba(0, 0, 0, 0.14)'); 57 + gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); 58 + heatCtx.fillStyle = gradient; 59 + heatCtx.fillRect(point.docX - radius, point.docY - radius, radius * 2, radius * 2); 60 + }); 61 + 62 + const imageData = heatCtx.getImageData(0, 0, width, height); 63 + const { data } = imageData; 64 + let maxAlpha = 1; 65 + for (let index = 3; index < data.length; index += 4) { 66 + maxAlpha = Math.max(maxAlpha, data[index]); 67 + } 68 + 69 + for (let index = 0; index < data.length; index += 4) { 70 + const intensity = data[index + 3] / maxAlpha; 71 + if (intensity < 0.02) { 72 + data[index + 3] = 0; 73 + continue; 74 + } 75 + 76 + const [r, g, b] = heatmapColor(intensity); 77 + data[index] = r; 78 + data[index + 1] = g; 79 + data[index + 2] = b; 80 + data[index + 3] = Math.round(70 + intensity * 185); 81 + } 82 + 83 + heatCtx.putImageData(imageData, 0, 0); 84 + ctx.drawImage(heatCanvas, 0, 0); 85 + return canvas; 86 + } 87 + 88 + export class DebriefRenderer { 89 + constructor({ galleryElement, labelElement }) { 90 + this.galleryElement = galleryElement; 91 + this.labelElement = labelElement; 92 + } 93 + 94 + async render(screens) { 95 + this.galleryElement.innerHTML = ''; 96 + 97 + if (!screens.length) { 98 + this.labelElement.textContent = 'No tracked screens were captured.'; 99 + return; 100 + } 101 + 102 + const totalPoints = screens.reduce((sum, screen) => sum + screen.gazePoints.length, 0); 103 + this.labelElement.textContent = `${screens.length} screens captured · ${totalPoints} gaze points`; 104 + 105 + for (const screen of screens) { 106 + const card = document.createElement('article'); 107 + card.className = 'screen-card'; 108 + 109 + const header = document.createElement('div'); 110 + header.className = 'screen-card-header'; 111 + header.innerHTML = ` 112 + <div> 113 + <h4>${screen.title || screen.key}</h4> 114 + <p>${screen.key}</p> 115 + </div> 116 + <div class="screen-card-stats"> 117 + <span>${screen.gazePoints.length} gaze points</span> 118 + <span>${screen.fixationCount} fixations</span> 119 + </div> 120 + `; 121 + 122 + const canvasWrap = document.createElement('div'); 123 + canvasWrap.className = 'screen-card-canvas-wrap'; 124 + 125 + try { 126 + const canvas = await renderHeatmapCanvas(screen); 127 + canvasWrap.appendChild(canvas); 128 + } catch (error) { 129 + const fallback = document.createElement('div'); 130 + fallback.className = 'screen-card-fallback'; 131 + fallback.textContent = screen.screenshot.status === 'failed' 132 + ? 'Screenshot capture failed for this screen.' 133 + : `Unable to render screen: ${error.message}`; 134 + canvasWrap.appendChild(fallback); 135 + } 136 + 137 + card.appendChild(header); 138 + card.appendChild(canvasWrap); 139 + this.galleryElement.appendChild(card); 140 + } 141 + } 142 + }
+295 -261
js/gazeTracker.js
··· 1 1 /** 2 - * UXET GazeTracker - WebGazer.js wrapper for gaze tracking 3 - * Collects viewport-relative gaze coordinates for heatmap generation 2 + * @typedef {Object} GazePoint 3 + * @property {number} timestamp 4 + * @property {string} screenKey 5 + * @property {number} viewportX 6 + * @property {number} viewportY 7 + * @property {number} iframeX 8 + * @property {number} iframeY 9 + * @property {boolean} inIframe 10 + * @property {number} scrollX 11 + * @property {number} scrollY 12 + * @property {number} docX 13 + * @property {number} docY 14 + * @property {number} viewportWidth 15 + * @property {number} viewportHeight 16 + * @property {number} documentWidth 17 + * @property {number} documentHeight 4 18 */ 5 19 20 + function createEmptyStats() { 21 + return { 22 + gazePoints: 0, 23 + fixations: 0, 24 + totalFixationDuration: 0, 25 + avgFixationDuration: 0, 26 + lastGazeX: 0, 27 + lastGazeY: 0 28 + }; 29 + } 30 + 31 + function createScreenRecord(metrics) { 32 + return { 33 + key: metrics.key, 34 + title: metrics.title, 35 + firstSeenAt: metrics.timestamp, 36 + lastSeenAt: metrics.timestamp, 37 + viewport: { 38 + width: metrics.viewportWidth, 39 + height: metrics.viewportHeight 40 + }, 41 + document: { 42 + width: metrics.documentWidth, 43 + height: metrics.documentHeight 44 + }, 45 + screenshot: { 46 + dataUrl: null, 47 + width: 0, 48 + height: 0, 49 + capturedAt: null, 50 + status: 'pending' 51 + }, 52 + gazePoints: [], 53 + interactionEvents: [], 54 + fixationCount: 0 55 + }; 56 + } 57 + 6 58 export class GazeTracker { 7 59 constructor() { 8 - this.isActive = false; 9 - this.gazeData = []; 10 - this.stats = this.createInitialStats(); 60 + this.isInitialized = false; 61 + this.mode = 'idle'; 62 + this.stats = createEmptyStats(); 63 + this.gazePoints = []; 64 + this.rawSamples = []; 65 + this.screenRecords = new Map(); 66 + this.currentScreenKey = null; 67 + this.currentMetrics = null; 11 68 this.onGazeData = null; 12 - this.onStatsUpdate = null; 13 - 14 - // Heatmap data — keyed by screen/URL 15 - this.heatmapData = {}; 16 - this.currentScreenKey = null; 17 - this.iframeRect = null; 18 - 19 - // Fixation detection 20 - this.fixationThreshold = 50; // px radius for fixation detection 21 - this.fixationMinDuration = 150; // ms minimum to count as fixation 69 + this.fixationThreshold = 50; 70 + this.fixationMinDuration = 150; 22 71 this.currentFixation = null; 23 - 24 - // Throttle gaze logging 25 - this.lastLogTime = 0; 26 - this.logInterval = 50; // ms — collect at ~20Hz for smooth heatmaps 72 + this.lastAcceptedAt = 0; 73 + this.logInterval = 50; 27 74 } 28 75 29 - createInitialStats() { 30 - return { 31 - gazePoints: 0, 32 - fixations: 0, 33 - totalFixationDuration: 0, 34 - avgFixationDuration: 0, 35 - lastGazeX: 0, 36 - lastGazeY: 0 37 - }; 38 - } 39 - 40 - /** 41 - * Initialize WebGazer - request camera permission and set up. 42 - * The webcam feed is always hidden to avoid distracting or 43 - * making the user self-conscious. 44 - */ 45 76 async initialize() { 77 + if (this.isInitialized) { 78 + return true; 79 + } 46 80 if (typeof webgazer === 'undefined') { 47 - console.error('WebGazer.js is not loaded'); 81 + console.error('WebGazer is not available.'); 48 82 return false; 49 83 } 50 84 51 85 try { 52 - // Don't persist calibration data between sessions 53 86 window.saveDataAcrossSessions = false; 54 - 55 - // Point MediaPipe face mesh assets to jsDelivr CDN (CORS-friendly) 56 - webgazer.params.faceMeshSolutionPath = 57 - 'https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh'; 58 - 87 + webgazer.params.faceMeshSolutionPath = 'https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh'; 59 88 webgazer 60 89 .setRegression('ridge') 61 90 .setGazeListener((data, elapsedTime) => { 62 - if (!this.isActive || !data) return; 91 + if (!data) { 92 + return; 93 + } 63 94 this.handleGaze(data, elapsedTime); 64 95 }); 65 96 66 97 await webgazer.begin(); 67 - 68 - // Always hide video feed and prediction dot — never show to user 69 98 webgazer.showVideoPreview(false); 70 99 webgazer.showPredictionPoints(false); 71 - 100 + webgazer.resume(); 101 + this.isInitialized = true; 72 102 return true; 73 - } catch (e) { 74 - console.error('Failed to initialize WebGazer:', e); 103 + } catch (error) { 104 + console.error('Failed to initialize WebGazer:', error); 75 105 return false; 76 106 } 77 107 } 78 108 79 - /** 80 - * Start collecting gaze data (after calibration). 81 - * @param {HTMLIFrameElement} iframe - the test app iframe for coordinate mapping 82 - */ 83 - start(iframe) { 84 - this.isActive = true; 85 - this.startTime = Date.now(); 86 - this.iframeElement = iframe; 87 - 88 - // Cache the iframe rect for coordinate mapping 89 - if (iframe) { 90 - this.iframeRect = iframe.getBoundingClientRect(); 91 - } 92 - 93 - if (typeof webgazer !== 'undefined') { 109 + setMode(mode) { 110 + this.mode = mode; 111 + if (mode === 'idle' && typeof webgazer !== 'undefined') { 112 + webgazer.pause(); 113 + } else if (this.isInitialized && typeof webgazer !== 'undefined') { 94 114 webgazer.resume(); 95 115 } 96 116 } 97 117 98 - /** 99 - * Update the cached iframe bounding rect. 100 - * Called on window resize to keep coordinate mapping accurate. 101 - */ 102 - updateIframeRect(iframe) { 103 - if (iframe) { 104 - this.iframeRect = iframe.getBoundingClientRect(); 105 - this.iframeElement = iframe; 118 + updateMetrics(metrics) { 119 + this.currentMetrics = metrics ? { ...metrics } : null; 120 + if (metrics) { 121 + this.currentScreenKey = metrics.key; 122 + this.ensureScreenRecord(metrics); 106 123 } 107 124 } 108 125 109 - /** 110 - * Update the current screen key for heatmap segmentation. 111 - * Called when the iframe navigates to a new page. 112 - * @param {string} screenKey - identifier for the current screen (e.g. URL path) 113 - */ 114 - setCurrentScreen(screenKey) { 115 - this.currentScreenKey = screenKey; 116 - if (!this.heatmapData[screenKey]) { 117 - this.heatmapData[screenKey] = []; 126 + ensureScreenRecord(metrics) { 127 + if (!metrics) { 128 + return null; 118 129 } 119 - } 120 130 121 - /** 122 - * Update the cached iframe bounding rect (call on resize). 123 - * @param {HTMLIFrameElement} iframe 124 - */ 125 - updateIframeRect(iframe) { 126 - if (iframe) { 127 - this.iframeRect = iframe.getBoundingClientRect(); 131 + if (!this.screenRecords.has(metrics.key)) { 132 + this.screenRecords.set(metrics.key, createScreenRecord(metrics)); 128 133 } 134 + 135 + const record = this.screenRecords.get(metrics.key); 136 + record.title = metrics.title || record.title; 137 + record.lastSeenAt = metrics.timestamp; 138 + record.viewport.width = Math.max(record.viewport.width, metrics.viewportWidth); 139 + record.viewport.height = Math.max(record.viewport.height, metrics.viewportHeight); 140 + record.document.width = Math.max(record.document.width, metrics.documentWidth); 141 + record.document.height = Math.max(record.document.height, metrics.documentHeight); 142 + return record; 129 143 } 130 144 131 - /** 132 - * Stop collecting gaze data 133 - */ 134 - stop() { 135 - this.isActive = false; 136 - 137 - // Finalize any in-progress fixation 138 - if (this.currentFixation) { 139 - this.finalizeFixation(); 145 + setScreenScreenshot(screenKey, screenshot) { 146 + const record = this.screenRecords.get(screenKey); 147 + if (!record) { 148 + return; 140 149 } 141 150 142 - if (typeof webgazer !== 'undefined') { 143 - webgazer.pause(); 144 - } 151 + record.screenshot = { 152 + dataUrl: screenshot?.dataUrl || null, 153 + width: screenshot?.width || 0, 154 + height: screenshot?.height || 0, 155 + capturedAt: screenshot?.capturedAt || null, 156 + status: screenshot?.dataUrl ? 'ready' : 'failed' 157 + }; 145 158 } 146 159 147 - /** 148 - * Fully shut down WebGazer 149 - */ 150 - async end() { 151 - this.isActive = false; 152 - this.currentFixation = null; 153 - 154 - if (typeof webgazer !== 'undefined') { 155 - try { 156 - webgazer.end(); 157 - } catch (e) { 158 - // WebGazer may already be ended 159 - } 160 + markScreenshotFailed(screenKey) { 161 + const record = this.screenRecords.get(screenKey); 162 + if (!record) { 163 + return; 160 164 } 161 165 162 - // Aggressively stop any active media tracks (Camera) 163 - try { 164 - const streams = [ 165 - window.stream, 166 - webgazer && webgazer.params && webgazer.params.videoStream, 167 - webgazer && webgazer.stream, 168 - document.querySelector('#webgazerVideoContainer video')?.srcObject 169 - ]; 166 + record.screenshot.status = 'failed'; 167 + record.screenshot.dataUrl = null; 168 + } 170 169 171 - streams.forEach(stream => { 172 - if (stream && stream.getTracks) { 173 - stream.getTracks().forEach(track => track.stop()); 174 - } 175 - }); 176 - } catch (e) { 177 - console.warn('[GazeTracker] Error stopping media tracks:', e); 170 + recordInteractionEvent(event) { 171 + const record = this.screenRecords.get(event.screenKey); 172 + if (record) { 173 + record.interactionEvents.push(event); 178 174 } 179 175 } 180 176 181 - /** 182 - * Reset tracker state 183 - */ 184 - reset() { 185 - this.gazeData = []; 186 - this.heatmapData = {}; 187 - this.currentScreenKey = null; 188 - this.iframeRect = null; 189 - this.stats = this.createInitialStats(); 190 - this.currentFixation = null; 191 - } 192 - 193 - /** 194 - * Handle incoming gaze data from WebGazer. 195 - * Converts viewport coords to iframe-relative coords for heatmap use. 196 - */ 197 - handleGaze(data, elapsedTime) { 198 - const viewportX = Math.round(data.x); 199 - const viewportY = Math.round(data.y); 177 + handleGaze(data) { 200 178 const now = Date.now(); 201 - 202 - this.stats.gazePoints++; 203 - this.stats.lastGazeX = viewportX; 204 - this.stats.lastGazeY = viewportY; 205 - 206 - // Fixation detection (in viewport coords) 207 - this.detectFixation(viewportX, viewportY, now); 179 + const sample = { 180 + timestamp: now, 181 + viewportX: Math.round(data.x), 182 + viewportY: Math.round(data.y), 183 + inIframe: false 184 + }; 208 185 209 - // Throttled logging 210 - if (now - this.lastLogTime >= this.logInterval) { 211 - this.lastLogTime = now; 186 + this.rawSamples.push(sample); 187 + if (this.rawSamples.length > 400) { 188 + this.rawSamples.shift(); 189 + } 212 190 213 - // Ensure rect is valid (handle layout shifts/start race condition) 214 - if ((!this.iframeRect || this.iframeRect.width === 0) && this.iframeElement) { 215 - this.iframeRect = this.iframeElement.getBoundingClientRect(); 216 - } 191 + if (!this.currentMetrics || this.mode === 'idle') { 192 + return; 193 + } 217 194 218 - // Map to iframe-relative coordinates for heatmap 219 - let iframeX = viewportX; 220 - let iframeY = viewportY; 221 - let inIframe = false; 195 + this.stats.lastGazeX = sample.viewportX; 196 + this.stats.lastGazeY = sample.viewportY; 222 197 223 - if (this.iframeRect && this.iframeRect.width > 0) { 224 - iframeX = viewportX - this.iframeRect.left; 225 - iframeY = viewportY - this.iframeRect.top; 226 - inIframe = ( 227 - iframeX >= 0 && iframeX <= this.iframeRect.width && 228 - iframeY >= 0 && iframeY <= this.iframeRect.height 229 - ); 230 - } 198 + const metrics = this.currentMetrics; 199 + const iframeX = sample.viewportX - metrics.iframeLeft; 200 + const iframeY = sample.viewportY - metrics.iframeTop; 201 + const inIframe = iframeX >= 0 && 202 + iframeX <= metrics.iframeWidth && 203 + iframeY >= 0 && 204 + iframeY <= metrics.iframeHeight; 231 205 232 - // Ensure we have a screen key, fallback if needed 233 - const screenKey = this.currentScreenKey || 'default'; 206 + const fullSample = { 207 + ...sample, 208 + iframeX: Math.round(iframeX), 209 + iframeY: Math.round(iframeY), 210 + inIframe, 211 + screenKey: metrics.key, 212 + scrollX: metrics.scrollX, 213 + scrollY: metrics.scrollY, 214 + docX: Math.round(iframeX + metrics.scrollX), 215 + docY: Math.round(iframeY + metrics.scrollY), 216 + viewportWidth: metrics.viewportWidth, 217 + viewportHeight: metrics.viewportHeight, 218 + documentWidth: metrics.documentWidth, 219 + documentHeight: metrics.documentHeight 220 + }; 234 221 235 - const gazePoint = { 236 - // Viewport coordinates (raw from WebGazer) 237 - viewportX, 238 - viewportY, 239 - // Iframe-relative coordinates (for heatmap overlay) 240 - x: iframeX, 241 - y: iframeY, 242 - // Normalized 0-1 coordinates (resolution-independent for heatmaps) 243 - normalizedX: this.iframeRect ? iframeX / this.iframeRect.width : 0, 244 - normalizedY: this.iframeRect ? iframeY / this.iframeRect.height : 0, 245 - inIframe, 246 - timestamp: now, 247 - screen: screenKey 248 - }; 222 + this.rawSamples[this.rawSamples.length - 1] = fullSample; 249 223 250 - this.gazeData.push(gazePoint); 224 + if (this.mode === 'recording' && now - this.lastAcceptedAt >= this.logInterval) { 225 + this.lastAcceptedAt = now; 226 + this.acceptGazePoint(fullSample); 227 + } 228 + } 251 229 252 - // Add to per-screen heatmap data 253 - if (inIframe) { 254 - if (!this.heatmapData[screenKey]) { 255 - this.heatmapData[screenKey] = []; 256 - } 257 - this.heatmapData[screenKey].push({ 258 - x: iframeX, 259 - y: iframeY, 260 - normalizedX: gazePoint.normalizedX, 261 - normalizedY: gazePoint.normalizedY, 262 - timestamp: now 263 - }); 264 - } 230 + acceptGazePoint(point) { 231 + this.stats.gazePoints += 1; 232 + this.gazePoints.push(point); 233 + const record = this.ensureScreenRecord({ 234 + key: point.screenKey, 235 + title: this.screenRecords.get(point.screenKey)?.title || point.screenKey, 236 + timestamp: point.timestamp, 237 + viewportWidth: point.viewportWidth, 238 + viewportHeight: point.viewportHeight, 239 + documentWidth: point.documentWidth, 240 + documentHeight: point.documentHeight 241 + }); 265 242 266 - if (this.onGazeData) { 267 - this.onGazeData(gazePoint); 268 - } 243 + if (record && point.inIframe) { 244 + record.gazePoints.push(point); 269 245 } 270 246 271 - if (this.onStatsUpdate) { 272 - this.onStatsUpdate({ ...this.stats }); 247 + this.detectFixation(point); 248 + 249 + if (this.onGazeData) { 250 + this.onGazeData(point); 273 251 } 274 252 } 275 253 276 - /** 277 - * Simple fixation detection based on spatial proximity over time 278 - */ 279 - detectFixation(x, y, now) { 254 + detectFixation(point) { 255 + const now = point.timestamp; 280 256 if (!this.currentFixation) { 281 257 this.currentFixation = { 282 - x, y, startTime: now, pointCount: 1 258 + x: point.docX, 259 + y: point.docY, 260 + screenKey: point.screenKey, 261 + startTime: now, 262 + pointCount: 1 283 263 }; 284 264 return; 285 265 } 286 266 287 - const dx = x - this.currentFixation.x; 288 - const dy = y - this.currentFixation.y; 289 - const distance = Math.sqrt(dx * dx + dy * dy); 267 + const dx = point.docX - this.currentFixation.x; 268 + const dy = point.docY - this.currentFixation.y; 269 + const distance = Math.hypot(dx, dy); 270 + const sameScreen = point.screenKey === this.currentFixation.screenKey; 290 271 291 - if (distance <= this.fixationThreshold) { 292 - this.currentFixation.pointCount++; 272 + if (sameScreen && distance <= this.fixationThreshold) { 273 + this.currentFixation.pointCount += 1; 293 274 this.currentFixation.x = 294 - (this.currentFixation.x * (this.currentFixation.pointCount - 1) + x) / 275 + (this.currentFixation.x * (this.currentFixation.pointCount - 1) + point.docX) / 295 276 this.currentFixation.pointCount; 296 277 this.currentFixation.y = 297 - (this.currentFixation.y * (this.currentFixation.pointCount - 1) + y) / 278 + (this.currentFixation.y * (this.currentFixation.pointCount - 1) + point.docY) / 298 279 this.currentFixation.pointCount; 299 - } else { 300 - this.finalizeFixation(); 301 - this.currentFixation = { 302 - x, y, startTime: now, pointCount: 1 303 - }; 280 + return; 304 281 } 282 + 283 + this.finalizeFixation(now); 284 + this.currentFixation = { 285 + x: point.docX, 286 + y: point.docY, 287 + screenKey: point.screenKey, 288 + startTime: now, 289 + pointCount: 1 290 + }; 305 291 } 306 292 307 - /** 308 - * Check if the current fixation is long enough and record it 309 - */ 310 - finalizeFixation() { 311 - if (!this.currentFixation) return; 293 + finalizeFixation(endTime = Date.now()) { 294 + if (!this.currentFixation) { 295 + return; 296 + } 312 297 313 - const duration = Date.now() - this.currentFixation.startTime; 298 + const duration = endTime - this.currentFixation.startTime; 314 299 if (duration >= this.fixationMinDuration) { 315 - this.stats.fixations++; 300 + this.stats.fixations += 1; 316 301 this.stats.totalFixationDuration += duration; 317 - this.stats.avgFixationDuration = Math.round( 318 - this.stats.totalFixationDuration / this.stats.fixations 319 - ); 302 + this.stats.avgFixationDuration = Math.round(this.stats.totalFixationDuration / this.stats.fixations); 303 + const record = this.screenRecords.get(this.currentFixation.screenKey); 304 + if (record) { 305 + record.fixationCount += 1; 306 + } 320 307 } 308 + 321 309 this.currentFixation = null; 322 310 } 323 311 324 - /** 325 - * Get current stats 326 - */ 312 + getCalibrationSamples(fromTimestamp) { 313 + return this.rawSamples.filter((sample) => sample.timestamp >= fromTimestamp); 314 + } 315 + 327 316 getStats() { 328 317 return { ...this.stats }; 329 318 } 330 319 331 - /** 332 - * Get all logged gaze data points 333 - */ 334 320 getGazeData() { 335 - return [...this.gazeData]; 321 + return [...this.gazePoints]; 336 322 } 337 323 338 - /** 339 - * Get heatmap data segmented by screen 340 - */ 341 - getHeatmapData() { 342 - // Deep copy 343 - const result = {}; 344 - for (const key in this.heatmapData) { 345 - result[key] = [...this.heatmapData[key]]; 346 - } 347 - return result; 324 + getScreenRecords() { 325 + return Array.from(this.screenRecords.values()).map((screen) => ({ 326 + ...screen, 327 + viewport: { ...screen.viewport }, 328 + document: { ...screen.document }, 329 + screenshot: { ...screen.screenshot }, 330 + gazePoints: [...screen.gazePoints], 331 + interactionEvents: [...screen.interactionEvents] 332 + })); 348 333 } 349 334 350 - /** 351 - * Export gaze data for session export 352 - */ 353 335 exportData() { 336 + const screens = this.getScreenRecords(); 337 + const screenLookup = {}; 338 + screens.forEach((screen) => { 339 + screenLookup[screen.key] = { 340 + key: screen.key, 341 + title: screen.title, 342 + firstSeenAt: screen.firstSeenAt, 343 + lastSeenAt: screen.lastSeenAt, 344 + documentWidth: screen.document.width, 345 + documentHeight: screen.document.height, 346 + viewportWidth: screen.viewport.width, 347 + viewportHeight: screen.viewport.height, 348 + screenshotCaptured: screen.screenshot.status === 'ready', 349 + screenshotWidth: screen.screenshot.width, 350 + screenshotHeight: screen.screenshot.height, 351 + gazePointCount: screen.gazePoints.length, 352 + fixationCount: screen.fixationCount 353 + }; 354 + }); 355 + 354 356 return { 355 357 stats: this.getStats(), 356 - gazePoints: this.getGazeData(), 357 - heatmapByScreen: this.getHeatmapData(), 358 - iframeSize: this.iframeRect ? { 359 - width: Math.round(this.iframeRect.width), 360 - height: Math.round(this.iframeRect.height) 361 - } : null 358 + points: this.getGazeData(), 359 + screens: screenLookup 362 360 }; 361 + } 362 + 363 + reset() { 364 + this.mode = 'idle'; 365 + this.stats = createEmptyStats(); 366 + this.gazePoints = []; 367 + this.rawSamples = []; 368 + this.screenRecords = new Map(); 369 + this.currentScreenKey = null; 370 + this.currentMetrics = null; 371 + this.currentFixation = null; 372 + this.lastAcceptedAt = 0; 373 + } 374 + 375 + async end() { 376 + this.finalizeFixation(); 377 + this.setMode('idle'); 378 + if (typeof webgazer !== 'undefined') { 379 + try { 380 + webgazer.pause(); 381 + const videoElement = webgazer.getVideoElement ? webgazer.getVideoElement() : null; 382 + const stream = videoElement?.srcObject || webgazer.params?.videoStream || null; 383 + if (stream?.getTracks) { 384 + stream.getTracks().forEach((track) => track.stop()); 385 + } 386 + webgazer.end(); 387 + } catch (error) { 388 + console.warn('[GazeTracker] Failed to end WebGazer cleanly:', error); 389 + } 390 + } 391 + document.querySelectorAll('#webgazerVideoContainer, video').forEach((element) => { 392 + if (element.id === 'webgazerVideoContainer' || element.dataset?.webgazerVideoFeed) { 393 + element.remove(); 394 + } 395 + }); 396 + this.isInitialized = false; 363 397 } 364 398 }
+287
js/iframeBridge.js
··· 1 + /** 2 + * @typedef {Object} ScreenMetrics 3 + * @property {string} key 4 + * @property {string} title 5 + * @property {number} scrollX 6 + * @property {number} scrollY 7 + * @property {number} viewportWidth 8 + * @property {number} viewportHeight 9 + * @property {number} documentWidth 10 + * @property {number} documentHeight 11 + * @property {number} iframeLeft 12 + * @property {number} iframeTop 13 + * @property {number} iframeWidth 14 + * @property {number} iframeHeight 15 + * @property {number} timestamp 16 + */ 17 + 18 + function debounce(fn, delay) { 19 + let timer = null; 20 + return (...args) => { 21 + window.clearTimeout(timer); 22 + timer = window.setTimeout(() => fn(...args), delay); 23 + }; 24 + } 25 + 26 + export class IframeBridge { 27 + constructor() { 28 + this.iframe = null; 29 + this.iframeWindow = null; 30 + this.iframeDocument = null; 31 + this.detachFns = []; 32 + this.restoreFns = []; 33 + this.currentMetrics = null; 34 + this.isAttached = false; 35 + this.onScreenChange = null; 36 + this.onScreenStable = null; 37 + this.onError = null; 38 + this._mutationObserver = null; 39 + this._resizeObserver = null; 40 + this._stableNotifier = debounce(() => { 41 + const metrics = this.getMetricsSnapshot(); 42 + if (metrics && this.onScreenStable) { 43 + this.onScreenStable(metrics); 44 + } 45 + }, 700); 46 + } 47 + 48 + attach(iframe) { 49 + this.detach(); 50 + this.iframe = iframe; 51 + 52 + try { 53 + this.iframeWindow = iframe.contentWindow; 54 + this.iframeDocument = iframe.contentDocument || this.iframeWindow?.document || null; 55 + if (!this.iframeWindow || !this.iframeDocument) { 56 + throw new Error('Same-origin iframe access is unavailable.'); 57 + } 58 + } catch (error) { 59 + this.handleError(error); 60 + return false; 61 + } 62 + 63 + this.isAttached = true; 64 + this.patchHistory(); 65 + this.bindNavigationEvents(); 66 + this.bindMetricsObservers(); 67 + this.refreshMetrics(true); 68 + this._stableNotifier(); 69 + return true; 70 + } 71 + 72 + patchHistory() { 73 + const history = this.iframeWindow.history; 74 + const methods = ['pushState', 'replaceState']; 75 + methods.forEach((methodName) => { 76 + const original = history[methodName].bind(history); 77 + history[methodName] = (...args) => { 78 + const result = original(...args); 79 + this.refreshMetrics(true); 80 + this._stableNotifier(); 81 + return result; 82 + }; 83 + this.restoreFns.push(() => { 84 + history[methodName] = original; 85 + }); 86 + }); 87 + } 88 + 89 + bindNavigationEvents() { 90 + const win = this.iframeWindow; 91 + const doc = this.iframeDocument; 92 + 93 + const onNavigation = () => { 94 + this.refreshMetrics(true); 95 + this._stableNotifier(); 96 + }; 97 + const onMetricsChange = () => { 98 + this.refreshMetrics(false); 99 + }; 100 + 101 + this.addListener(win, 'hashchange', onNavigation); 102 + this.addListener(win, 'popstate', onNavigation); 103 + this.addListener(win, 'resize', onNavigation); 104 + this.addListener(win, 'scroll', onMetricsChange, { passive: true }); 105 + this.addListener(doc, 'scroll', onMetricsChange, { passive: true, capture: true }); 106 + } 107 + 108 + bindMetricsObservers() { 109 + const docEl = this.iframeDocument.documentElement; 110 + const body = this.iframeDocument.body; 111 + 112 + if (window.ResizeObserver) { 113 + this._resizeObserver = new ResizeObserver(() => { 114 + this.refreshMetrics(false); 115 + this._stableNotifier(); 116 + }); 117 + this._resizeObserver.observe(docEl); 118 + if (body) { 119 + this._resizeObserver.observe(body); 120 + } 121 + } 122 + 123 + if (window.MutationObserver) { 124 + this._mutationObserver = new MutationObserver(() => { 125 + this.refreshMetrics(false); 126 + this._stableNotifier(); 127 + }); 128 + this._mutationObserver.observe(docEl, { 129 + childList: true, 130 + subtree: true, 131 + attributes: true, 132 + characterData: true 133 + }); 134 + } 135 + } 136 + 137 + refreshMetrics(screenChanged = false) { 138 + if (!this.isAttached) { 139 + return null; 140 + } 141 + 142 + try { 143 + const win = this.iframeWindow; 144 + const doc = this.iframeDocument; 145 + const docEl = doc.documentElement; 146 + const body = doc.body; 147 + const rect = this.iframe.getBoundingClientRect(); 148 + const key = `${win.location.pathname}${win.location.search}${win.location.hash}`; 149 + const metrics = { 150 + key, 151 + title: doc.title || key || 'Untitled screen', 152 + scrollX: Math.round(win.scrollX || docEl.scrollLeft || body?.scrollLeft || 0), 153 + scrollY: Math.round(win.scrollY || docEl.scrollTop || body?.scrollTop || 0), 154 + viewportWidth: Math.round(win.innerWidth || docEl.clientWidth || 0), 155 + viewportHeight: Math.round(win.innerHeight || docEl.clientHeight || 0), 156 + documentWidth: Math.round(Math.max( 157 + docEl.scrollWidth, 158 + docEl.clientWidth, 159 + body?.scrollWidth || 0, 160 + body?.clientWidth || 0 161 + )), 162 + documentHeight: Math.round(Math.max( 163 + docEl.scrollHeight, 164 + docEl.clientHeight, 165 + body?.scrollHeight || 0, 166 + body?.clientHeight || 0 167 + )), 168 + iframeLeft: rect.left, 169 + iframeTop: rect.top, 170 + iframeWidth: rect.width, 171 + iframeHeight: rect.height, 172 + timestamp: Date.now() 173 + }; 174 + 175 + const previousKey = this.currentMetrics?.key; 176 + this.currentMetrics = metrics; 177 + 178 + if ((screenChanged || previousKey !== metrics.key) && this.onScreenChange) { 179 + this.onScreenChange(metrics); 180 + } 181 + 182 + return metrics; 183 + } catch (error) { 184 + this.handleError(error); 185 + return null; 186 + } 187 + } 188 + 189 + getMetricsSnapshot() { 190 + if (!this.currentMetrics) { 191 + return this.refreshMetrics(false); 192 + } 193 + 194 + return { ...this.currentMetrics }; 195 + } 196 + 197 + async captureScreenshot() { 198 + if (!this.isAttached) { 199 + throw new Error('Iframe bridge is not attached.'); 200 + } 201 + if (typeof html2canvas === 'undefined') { 202 + throw new Error('html2canvas is not available.'); 203 + } 204 + 205 + const doc = this.iframeDocument; 206 + const docEl = doc.documentElement; 207 + const body = doc.body; 208 + const width = Math.max(docEl.scrollWidth, body?.scrollWidth || 0, docEl.clientWidth); 209 + const height = Math.max(docEl.scrollHeight, body?.scrollHeight || 0, docEl.clientHeight); 210 + const previousOverflow = docEl.style.overflow; 211 + const previousBodyOverflow = body ? body.style.overflow : ''; 212 + 213 + try { 214 + docEl.style.overflow = 'visible'; 215 + if (body) { 216 + body.style.overflow = 'visible'; 217 + } 218 + 219 + const canvas = await html2canvas(doc.body || docEl, { 220 + backgroundColor: '#ffffff', 221 + useCORS: true, 222 + logging: false, 223 + scale: 1, 224 + width, 225 + height, 226 + windowWidth: width, 227 + windowHeight: height, 228 + x: 0, 229 + y: 0, 230 + scrollX: 0, 231 + scrollY: 0 232 + }); 233 + 234 + return { 235 + dataUrl: canvas.toDataURL('image/png'), 236 + width: canvas.width, 237 + height: canvas.height, 238 + capturedAt: Date.now() 239 + }; 240 + } finally { 241 + docEl.style.overflow = previousOverflow; 242 + if (body) { 243 + body.style.overflow = previousBodyOverflow; 244 + } 245 + } 246 + } 247 + 248 + detach() { 249 + this.detachFns.forEach((fn) => fn()); 250 + this.detachFns = []; 251 + 252 + this.restoreFns.forEach((fn) => fn()); 253 + this.restoreFns = []; 254 + 255 + if (this._mutationObserver) { 256 + this._mutationObserver.disconnect(); 257 + this._mutationObserver = null; 258 + } 259 + 260 + if (this._resizeObserver) { 261 + this._resizeObserver.disconnect(); 262 + this._resizeObserver = null; 263 + } 264 + 265 + this.currentMetrics = null; 266 + this.iframe = null; 267 + this.iframeWindow = null; 268 + this.iframeDocument = null; 269 + this.isAttached = false; 270 + } 271 + 272 + addListener(target, eventName, handler, options = false) { 273 + target.addEventListener(eventName, handler, options); 274 + this.detachFns.push(() => { 275 + target.removeEventListener(eventName, handler, options); 276 + }); 277 + } 278 + 279 + handleError(error) { 280 + this.isAttached = false; 281 + if (this.onError) { 282 + this.onError(error); 283 + } else { 284 + console.error('[IframeBridge]', error); 285 + } 286 + } 287 + }
+274 -463
js/main.js
··· 1 - import { Tracker } from './tracker.js'; 2 1 import { Session } from './session.js'; 2 + import { Tracker } from './tracker.js'; 3 3 import { GazeTracker } from './gazeTracker.js'; 4 + import { IframeBridge } from './iframeBridge.js'; 5 + import { CalibrationController } from './calibration.js'; 6 + import { WinConditionRegistry } from './winConditions.js'; 7 + import { DebriefRenderer } from './debriefRenderer.js'; 4 8 5 9 class UXETApp { 6 10 constructor() { 7 - this.tracker = new Tracker(); 8 11 this.session = new Session(); 12 + this.tracker = new Tracker(); 9 13 this.gazeTracker = new GazeTracker(); 10 - this.currentTask = ''; 14 + this.bridge = new IframeBridge(); 15 + this.winConditions = new WinConditionRegistry(); 11 16 this.selectedApp = null; 17 + this.captureJobs = new Map(); 18 + this.calibrationPassed = false; 19 + this.debugOverride = false; 12 20 this.gazeInitialized = false; 13 21 14 - this._winWatcher = null; 15 - 16 - this.calibrationClicks = {}; 17 - this.calibratedPoints = 0; 18 - this.CLICKS_PER_POINT = 5; 19 - this.TOTAL_POINTS = 9; 20 - 21 22 this.elements = { 22 - appContainer: document.getElementById('app-container'), 23 23 appSelect: document.getElementById('app-select'), 24 24 loadAppBtn: document.getElementById('load-app-btn'), 25 25 resetBtn: document.getElementById('reset-btn'), 26 26 exportBtn: document.getElementById('export-btn'), 27 + startTestBtn: document.getElementById('start-test-btn'), 28 + debugToggleBtn: document.getElementById('debug-toggle-btn'), 29 + debugPanel: document.getElementById('debug-panel'), 30 + debugSkipBtn: document.getElementById('debug-skip-calibration'), 31 + debugEndBtn: document.getElementById('debug-end-test'), 27 32 28 33 sessionStatus: document.getElementById('session-status'), 29 34 sessionTimer: document.getElementById('session-timer'), 30 35 currentTaskText: document.getElementById('current-task-text'), 36 + sessionMessage: document.getElementById('session-message'), 31 37 32 38 iframePlaceholder: document.getElementById('iframe-placeholder'), 33 39 iframe: document.getElementById('test-iframe'), 34 40 35 41 calibrationScreen: document.getElementById('calibration-screen'), 36 - calibrationControls: document.getElementById('calibration-controls'), 37 - calibrationGrid: document.getElementById('calibration-grid'), 38 - debugSkipBtn: document.getElementById('debug-skip-calibration'), 39 - calibrationProgress: document.getElementById('calibration-progress-text'), 40 - startTestBtn: document.getElementById('start-test-btn'), 42 + calibrationStage: document.getElementById('calibration-stage'), 43 + calibrationTarget: document.getElementById('calibration-target'), 44 + calibrationInstruction: document.getElementById('calibration-instruction'), 45 + calibrationProgress: document.getElementById('calibration-progress'), 46 + calibrationClicks: document.getElementById('calibration-clicks'), 47 + calibrationQuality: document.getElementById('calibration-quality'), 48 + calibrationFeedback: document.getElementById('calibration-feedback'), 41 49 42 50 debriefScreen: document.getElementById('debrief-screen'), 43 51 debriefTime: document.getElementById('debrief-time'), ··· 49 57 debriefGazePoints: document.getElementById('debrief-gaze-points'), 50 58 debriefFixations: document.getElementById('debrief-fixations'), 51 59 debriefFixationDuration: document.getElementById('debrief-fixation-duration'), 60 + galleryLabel: document.getElementById('screen-gallery-label'), 61 + gallery: document.getElementById('screen-gallery') 62 + }; 52 63 53 - heatmapCanvas: document.getElementById('heatmap-canvas'), 54 - heatmapLabel: document.getElementById('heatmap-label'), 55 - }; 64 + this.debriefRenderer = new DebriefRenderer({ 65 + galleryElement: this.elements.gallery, 66 + labelElement: this.elements.galleryLabel 67 + }); 68 + this.calibration = new CalibrationController({ 69 + gazeTracker: this.gazeTracker, 70 + elements: { 71 + calibrationStage: this.elements.calibrationStage, 72 + calibrationTarget: this.elements.calibrationTarget, 73 + calibrationInstruction: this.elements.calibrationInstruction, 74 + calibrationProgress: this.elements.calibrationProgress, 75 + calibrationClicks: this.elements.calibrationClicks, 76 + calibrationQuality: this.elements.calibrationQuality, 77 + calibrationFeedback: this.elements.calibrationFeedback 78 + } 79 + }); 56 80 57 81 this.init(); 58 82 } 59 83 60 84 init() { 61 85 this.bindEvents(); 62 - this.setupCallbacks(); 86 + this.bindCallbacks(); 87 + this.updateUiForState('idle'); 63 88 } 64 89 65 90 bindEvents() { 66 - this.elements.appSelect.addEventListener('change', (e) => this.selectApp(e.target)); 67 - this.elements.loadAppBtn.addEventListener('click', () => this.loadApp()); 68 - this.elements.startTestBtn.addEventListener('click', () => this.beginTesting()); 91 + this.elements.appSelect.addEventListener('change', (event) => this.selectApp(event.target)); 92 + this.elements.loadAppBtn.addEventListener('click', () => this.loadSelectedApp()); 69 93 this.elements.resetBtn.addEventListener('click', () => this.resetSession()); 70 94 this.elements.exportBtn.addEventListener('click', () => this.exportData()); 95 + this.elements.startTestBtn.addEventListener('click', () => this.beginTesting()); 96 + this.elements.debugToggleBtn.addEventListener('click', () => { 97 + this.elements.debugPanel.classList.toggle('hidden'); 98 + }); 71 99 this.elements.debugSkipBtn.addEventListener('click', () => this.skipCalibration()); 100 + this.elements.debugEndBtn.addEventListener('click', () => this.finishTest({ strategy: 'debug-manual' })); 72 101 this.elements.iframe.addEventListener('load', () => this.onIframeLoad()); 73 - 74 - for (let i = 1; i <= this.TOTAL_POINTS; i++) { 75 - const point = document.getElementById(`cal-pt-${i}`); 76 - if (point) { 77 - point.addEventListener('click', () => this.onCalibrationPointClick(i, point)); 78 - } 79 - } 80 - 102 + this.elements.calibrationTarget.addEventListener('click', () => this.calibration.handleTargetClick()); 81 103 window.addEventListener('resize', () => { 82 - if (this.gazeTracker && this.elements.iframe) { 83 - this.gazeTracker.updateIframeRect(this.elements.iframe); 104 + const metrics = this.bridge.refreshMetrics(false); 105 + if (metrics) { 106 + this.gazeTracker.updateMetrics(metrics); 84 107 } 85 108 }); 86 109 } 87 110 88 - setupCallbacks() { 89 - this.session.onStatusChange = (status) => this.updateSessionStatus(status); 90 - this.session.onTimerUpdate = (time) => { 91 - this.elements.sessionTimer.textContent = time; 111 + bindCallbacks() { 112 + this.session.onStateChange = (state, details) => this.updateUiForState(state, details); 113 + this.session.onTimerUpdate = (formatted) => { 114 + this.elements.sessionTimer.textContent = formatted; 115 + }; 116 + 117 + this.tracker.onEvent = (event) => { 118 + this.gazeTracker.recordInteractionEvent(event); 119 + }; 120 + 121 + this.bridge.onScreenChange = (metrics) => { 122 + this.gazeTracker.updateMetrics(metrics); 123 + }; 124 + this.bridge.onScreenStable = (metrics) => { 125 + this.gazeTracker.updateMetrics(metrics); 126 + this.captureScreen(metrics.key); 127 + }; 128 + this.bridge.onError = (error) => { 129 + this.failSession(error.message || 'Failed to attach to iframe.'); 92 130 }; 93 131 94 - this.gazeTracker.onGazeData = (gazePoint) => { 95 - if (this.tracker.isRecording) { 96 - this.tracker.logEvent('gaze', `Gaze at (${gazePoint.x}, ${gazePoint.y})`); 132 + this.calibration.onPass = (result) => { 133 + if (result.passed) { 134 + this.calibrationPassed = true; 135 + this.debugOverride = false; 136 + this.session.setState('ready_to_start'); 137 + } else { 138 + this.elements.sessionMessage.textContent = 'Calibration average was too noisy. Restarting calibration.'; 139 + window.setTimeout(() => this.calibration.start(), 700); 97 140 } 98 141 }; 99 142 } ··· 104 147 this.selectedApp = null; 105 148 return; 106 149 } 107 - 108 150 this.selectedApp = { 109 151 value: option.value, 110 152 task: option.dataset.task, 111 - name: option.textContent, 112 - win: option.dataset.win || null 153 + name: option.textContent.trim(), 154 + win: option.dataset.win || '' 113 155 }; 114 156 } 115 157 116 - loadApp() { 158 + async loadSelectedApp() { 117 159 if (!this.selectedApp) { 118 - alert('Please select an app to test'); 160 + window.alert('Select an app to test.'); 119 161 return; 120 162 } 121 163 122 - this.currentTask = this.selectedApp.task; 123 - this.elements.currentTaskText.textContent = this.currentTask; 124 - 164 + await this.resetRuntimeOnly(); 165 + this.session.reset(); 166 + this.calibrationPassed = false; 167 + this.debugOverride = false; 168 + this.session.setAppName(this.selectedApp.name); 169 + this.session.setTask(this.selectedApp.task); 170 + this.elements.currentTaskText.textContent = this.selectedApp.task; 171 + this.elements.iframePlaceholder.classList.add('hidden'); 172 + this.elements.iframe.classList.remove('hidden'); 125 173 this.elements.iframe.src = this.selectedApp.value; 126 - this.session.setAppName(this.selectedApp.value); 127 - this.session.setTask(this.currentTask); 174 + this.session.setState('loading_app'); 128 175 } 129 176 130 177 async onIframeLoad() { 131 - const src = this.elements.iframe.src; 132 - if (!src || src.includes('about:blank')) return; 133 - 134 - this.elements.iframePlaceholder.classList.add('hidden'); 135 - this.elements.iframe.classList.remove('hidden'); 136 - 137 - this.elements.calibrationScreen.classList.remove('hidden'); 138 - this.elements.calibrationControls.classList.remove('hidden'); 139 - 140 - if (!this.gazeInitialized) { 141 - const success = await this.gazeTracker.initialize(); 142 - if (success) { 143 - this.gazeInitialized = true; 144 - } else { 145 - console.warn('WebGazer could not initialize. Gaze tracking unavailable.'); 146 - } 178 + if (!this.selectedApp || !this.elements.iframe.src || this.elements.iframe.src.includes('about:blank')) { 179 + return; 147 180 } 148 181 149 - this.startCalibration(); 150 - } 151 - 152 - startCalibration() { 153 - this.calibrationClicks = {}; 154 - this.calibratedPoints = 0; 155 - this.elements.startTestBtn.classList.add('hidden'); 156 - 157 - for (let i = 1; i <= this.TOTAL_POINTS; i++) { 158 - const point = document.getElementById(`cal-pt-${i}`); 159 - if (point) { 160 - point.setAttribute('data-clicks', '0'); 161 - point.classList.remove('done'); 162 - point.style.pointerEvents = ''; 163 - } 182 + const attached = this.bridge.attach(this.elements.iframe); 183 + if (!attached) { 184 + return; 164 185 } 165 - this.updateCalibrationProgress(); 166 - } 167 186 168 - onCalibrationPointClick(index, pointEl) { 169 - if (!this.calibrationClicks[index]) { 170 - this.calibrationClicks[index] = 0; 187 + const initialized = await this.gazeTracker.initialize(); 188 + if (!initialized) { 189 + this.failSession('WebGazer failed to initialize.'); 190 + return; 171 191 } 172 192 173 - this.calibrationClicks[index]++; 174 - const clicks = this.calibrationClicks[index]; 175 - 176 - if (clicks >= this.CLICKS_PER_POINT) { 177 - pointEl.classList.add('done'); 178 - this.calibratedPoints++; 179 - this.updateCalibrationProgress(); 180 - 181 - if (this.calibratedPoints >= this.TOTAL_POINTS) { 182 - this.onCalibrationComplete(); 183 - } 184 - } 185 - } 186 - 187 - updateCalibrationProgress() { 188 - this.elements.calibrationProgress.textContent = `${this.calibratedPoints} of ${this.TOTAL_POINTS} points calibrated`; 189 - } 190 - 191 - onCalibrationComplete() { 192 - this.elements.startTestBtn.classList.remove('hidden'); 193 + this.gazeInitialized = true; 194 + const metrics = this.bridge.getMetricsSnapshot(); 195 + this.gazeTracker.updateMetrics(metrics); 196 + this.gazeTracker.setMode('calibration'); 197 + this.session.setState('calibrating'); 198 + this.calibration.start(); 199 + this.captureScreen(metrics.key); 193 200 } 194 201 195 202 skipCalibration() { 196 - console.log('Skipping calibration'); 197 - this.beginTesting(); 198 - } 199 - 200 - beginTesting() { 201 - this.elements.calibrationScreen.classList.add('hidden'); 202 - this.elements.calibrationControls.classList.add('hidden'); 203 - 204 - const attached = this.tracker.attachToIframe(this.elements.iframe); 205 - if (!attached) console.warn('Could not attach tracker to iframe'); 206 - 207 - this.session.start(); 208 - this.tracker.start(); 209 - 210 - if (this.gazeInitialized) { 211 - setTimeout(() => { 212 - this.gazeTracker.start(this.elements.iframe); 213 - this.trackIframeScreen(); 214 - this.setupIframeNavigationTracking(); 215 - }, 500); 203 + if (this.session.state !== 'calibrating') { 204 + return; 216 205 } 217 206 218 - this._winStartTimer = setTimeout(() => { 219 - this._winStartTimer = null; 220 - this.setupWinCondition(); 221 - }, 1500); 207 + this.debugOverride = true; 208 + this.calibrationPassed = false; 209 + this.elements.calibrationFeedback.textContent = 'Calibration bypassed via debug override'; 210 + this.elements.calibrationQuality.textContent = 'Debug override'; 211 + this.session.setState('ready_to_start'); 222 212 } 223 213 224 - setupWinCondition() { 225 - this.teardownWinCondition(); 226 - const winSpec = this.selectedApp?.win; 227 - if (!winSpec) { 228 - console.warn('[UXET] No win condition defined'); 214 + beginTesting() { 215 + if (!this.selectedApp) { 229 216 return; 230 217 } 231 - const iframe = this.elements.iframe; 232 - 233 - if (winSpec === 'postMessage') { 234 - const handler = (e) => { 235 - if (e.data?.type === 'UXET_TASK_COMPLETE') { 236 - this.onTaskComplete({ strategy: 'postMessage' }); 237 - } 238 - }; 239 - window.addEventListener('message', handler); 240 - this._winWatcher = { type: 'postMessage', handler }; 218 + if (!(this.calibrationPassed || this.debugOverride)) { 219 + this.elements.sessionMessage.textContent = 'Calibration must pass before recording can start.'; 241 220 return; 242 221 } 243 222 244 - const colonIdx = winSpec.indexOf(':'); 245 - if (colonIdx === -1) return; 246 - 247 - const strategy = winSpec.slice(0, colonIdx); 248 - const value = winSpec.slice(colonIdx + 1); 249 - 250 - switch (strategy) { 251 - case 'selector': { 252 - const interval = setInterval(() => { 253 - if (this.session.status !== 'recording') return; 254 - try { 255 - const doc = iframe.contentDocument || iframe.contentWindow?.document; 256 - if (!doc) return; 257 - const el = doc.querySelector(value); 258 - if (el && this._isVisible(el)) { 259 - this.onTaskComplete({ strategy: 'selector', selector: value }); 260 - } 261 - } catch (err) { } 262 - }, 300); 263 - this._winWatcher = { type: 'selector', interval }; 264 - break; 265 - } 266 - case 'url': { 267 - const pattern = this._globToRegex(value); 268 - const interval = setInterval(() => { 269 - if (this.session.status !== 'recording') return; 270 - try { 271 - const href = iframe.contentWindow?.location?.href; 272 - if (href && pattern.test(href)) { 273 - this.onTaskComplete({ strategy: 'url', url: href }); 274 - } 275 - } catch (err) { } 276 - }, 500); 277 - this._winWatcher = { type: 'url', interval }; 278 - break; 279 - } 280 - case 'text': { 281 - const interval = setInterval(() => { 282 - if (this.session.status !== 'recording') return; 283 - try { 284 - const doc = iframe.contentDocument || iframe.contentWindow?.document; 285 - if (!doc?.body) return; 286 - if (doc.body.innerText.includes(value)) { 287 - this.onTaskComplete({ strategy: 'text', matchedText: value }); 288 - } 289 - } catch (err) { } 290 - }, 500); 291 - this._winWatcher = { type: 'text', interval }; 292 - break; 293 - } 294 - } 295 - } 296 - 297 - teardownWinCondition() { 298 - if (!this._winWatcher) return; 299 - switch (this._winWatcher.type) { 300 - case 'postMessage': 301 - window.removeEventListener('message', this._winWatcher.handler); 302 - break; 303 - case 'selector': 304 - case 'url': 305 - case 'text': 306 - clearInterval(this._winWatcher.interval); 307 - break; 223 + const attached = this.tracker.attach(this.bridge); 224 + if (!attached) { 225 + this.failSession('Failed to attach tracker to iframe.'); 226 + return; 308 227 } 309 - this._winWatcher = null; 310 - if (this._winStartTimer) { 311 - clearTimeout(this._winStartTimer); 312 - this._winStartTimer = null; 313 - } 314 - } 315 228 316 - _isVisible(el) { 317 - if (!el) return false; 318 - try { 319 - const win = el.ownerDocument?.defaultView; 320 - if (!win) return false; 321 - const style = win.getComputedStyle(el); 322 - return (style.display !== 'none' && style.visibility !== 'hidden' && parseFloat(style.opacity) > 0 && el.offsetWidth > 0 && el.offsetHeight > 0); 323 - } catch (e) { return false; } 229 + this.session.startRecording(); 230 + this.gazeTracker.setMode('recording'); 231 + this.tracker.start(); 232 + this.winConditions.start(this.selectedApp.win, { 233 + bridge: this.bridge, 234 + session: this.session, 235 + complete: (details) => this.finishTest(details) 236 + }); 324 237 } 325 238 326 - _globToRegex(glob) { 327 - const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&'); 328 - const regexStr = escaped.replace(/\*/g, '.*'); 329 - return new RegExp(`^${regexStr}$`, 'i'); 330 - } 331 - 332 - trackIframeScreen() { 333 - try { 334 - const iframeUrl = this.elements.iframe.contentWindow.location.href; 335 - const url = new URL(iframeUrl); 336 - this.gazeTracker.setCurrentScreen(url.pathname + url.hash); 337 - } catch (e) { 338 - this.gazeTracker.setCurrentScreen(this.selectedApp?.value || 'unknown'); 339 - } 340 - } 341 - 342 - setupIframeNavigationTracking() { 343 - try { 344 - const iframeWin = this.elements.iframe.contentWindow; 345 - iframeWin.addEventListener('hashchange', () => this.trackIframeScreen()); 346 - iframeWin.addEventListener('popstate', () => this.trackIframeScreen()); 347 - const iframeDoc = this.elements.iframe.contentDocument; 348 - if (iframeDoc) { 349 - iframeDoc.addEventListener('click', () => { 350 - setTimeout(() => this.trackIframeScreen(), 100); 351 - }); 352 - } 353 - } catch (e) { } 354 - } 355 - 356 - async onTaskComplete(details) { 357 - if (this.session.status !== 'recording') return; 358 - try { 359 - this.teardownWinCondition(); 360 - this.session.stop(); 361 - this.tracker.stop(); 362 - 363 - document.querySelectorAll('video').forEach(v => { 364 - if (v.srcObject) { 365 - v.srcObject.getTracks().forEach(t => t.stop()); 366 - v.srcObject = null; 367 - } 368 - }); 369 - 370 - if (typeof webgazer !== 'undefined') { 371 - try { 372 - const wgVideo = webgazer.getVideoElement ? webgazer.getVideoElement() : null; 373 - if (wgVideo && wgVideo.srcObject) { 374 - wgVideo.srcObject.getTracks().forEach(t => t.stop()); 375 - wgVideo.srcObject = null; 376 - } 377 - } catch (e) { } 378 - } 379 - 380 - if (this.gazeInitialized) { 381 - await this.gazeTracker.end(); 382 - this.gazeInitialized = false; 383 - } 384 - 385 - document.querySelectorAll('video').forEach(v => v.remove()); 386 - const wgContainer = document.getElementById('webgazerVideoContainer'); 387 - if (wgContainer) wgContainer.remove(); 388 - 389 - this.tracker.logEvent('system', `Task completed — ${details?.strategy || 'manual'}`); 390 - 391 - this.elements.iframe.classList.add('hidden'); 392 - 393 - const stats = this.tracker.getStats(); 394 - const gazeStats = this.gazeTracker.getStats(); 395 - const timeFormatted = this.formatTime(this.session.elapsed); 396 - 397 - this.elements.debriefTime.textContent = timeFormatted; 398 - this.elements.debriefClicks.textContent = stats.mouse.clicks.toLocaleString(); 399 - this.elements.debriefKeys.textContent = stats.keyboard.totalKeys.toLocaleString(); 400 - this.elements.debriefDistance.textContent = Math.round(stats.mouse.distance).toLocaleString(); 401 - this.elements.debriefScrolls.textContent = stats.mouse.scrollEvents.toLocaleString(); 402 - this.elements.debriefVelocity.textContent = stats.mouse.avgVelocity.toLocaleString(); 403 - this.elements.debriefGazePoints.textContent = gazeStats.gazePoints.toLocaleString(); 404 - this.elements.debriefFixations.textContent = gazeStats.fixations.toLocaleString(); 405 - this.elements.debriefFixationDuration.textContent = gazeStats.avgFixationDuration.toLocaleString(); 406 - 407 - this.elements.debriefScreen.classList.remove('hidden'); 408 - 409 - requestAnimationFrame(() => { 410 - requestAnimationFrame(() => { 411 - this.renderHeatmap(); 412 - }); 413 - }); 414 - } catch (e) { 415 - console.error('[UXET] Critical error in onTaskComplete:', e); 416 - this.elements.iframe.classList.add('hidden'); 417 - this.elements.debriefScreen.classList.remove('hidden'); 418 - } 419 - } 420 - 421 - renderHeatmap() { 422 - const gazePoints = this.gazeTracker.getGazeData(); 423 - const canvas = this.elements.heatmapCanvas; 424 - const container = canvas.parentElement; 425 - 426 - if (!gazePoints.length) { 427 - this.elements.heatmapLabel.textContent = 'No gaze data recorded'; 239 + async finishTest(details) { 240 + if (this.session.state !== 'recording') { 428 241 return; 429 242 } 430 243 431 - const rect = container.getBoundingClientRect(); 432 - const dpr = window.devicePixelRatio || 1; 433 - canvas.width = rect.width * dpr; 434 - canvas.height = rect.height * dpr; 435 - const ctx = canvas.getContext('2d'); 436 - ctx.scale(dpr, dpr); 437 - 438 - const w = rect.width; 439 - const h = rect.height; 440 - const iframeRect = this.gazeTracker.iframeRect; 441 - 442 - ctx.fillStyle = '#fff'; 443 - ctx.fillRect(0, 0, w, h); 444 - 445 - ctx.strokeStyle = '#eee'; 446 - ctx.lineWidth = 1; 447 - const gridSize = 40; 448 - for (let x = 0; x < w; x += gridSize) { 449 - ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); 450 - } 451 - for (let y = 0; y < h; y += gridSize) { 452 - ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); 453 - } 244 + this.session.setState('finishing'); 245 + this.winConditions.stop(); 246 + this.tracker.stop(); 247 + this.gazeTracker.finalizeFixation(); 454 248 455 - let mappedPoints; 456 - if (iframeRect && iframeRect.width > 0 && iframeRect.height > 0) { 457 - const scaleX = w / iframeRect.width; 458 - const scaleY = h / iframeRect.height; 459 - mappedPoints = gazePoints.map(p => ({ 460 - px: (p.viewportX - iframeRect.left) * scaleX, 461 - py: (p.viewportY - iframeRect.top) * scaleY, 462 - })); 463 - } else { 464 - const screenW = window.screen.width || 1920; 465 - const screenH = window.screen.height || 1080; 466 - mappedPoints = gazePoints.map(p => ({ 467 - px: (p.viewportX / screenW) * w, 468 - py: (p.viewportY / screenH) * h, 469 - })); 249 + const currentMetrics = this.bridge.getMetricsSnapshot(); 250 + if (currentMetrics) { 251 + this.gazeTracker.updateMetrics(currentMetrics); 252 + await this.captureScreen(currentMetrics.key); 470 253 } 471 254 472 - mappedPoints = mappedPoints.filter(p => p.px >= 0 && p.px <= w && p.py >= 0 && p.py <= h); 255 + this.session.stopRecording('finishing'); 256 + await this.renderDebrief(details); 257 + this.session.setState('complete'); 258 + } 473 259 474 - if (!mappedPoints.length) { 475 - ctx.fillStyle = '#333'; 476 - ctx.font = '14px sans-serif'; 477 - ctx.textAlign = 'center'; 478 - ctx.fillText(`${gazePoints.length} gaze points recorded but all outside viewport`, w / 2, h / 2); 479 - this.elements.heatmapLabel.textContent = 'Gaze data outside app area.'; 480 - return; 260 + async captureScreen(screenKey) { 261 + if (!this.bridge.isAttached || !screenKey) { 262 + return null; 481 263 } 482 - 483 - const heatCanvas = document.createElement('canvas'); 484 - heatCanvas.width = Math.round(w); 485 - heatCanvas.height = Math.round(h); 486 - const heatCtx = heatCanvas.getContext('2d'); 487 - 488 - const radius = Math.max(30, Math.min(w, h) * 0.06); 489 - 490 - mappedPoints.forEach(point => { 491 - const gradient = heatCtx.createRadialGradient( 492 - point.px, point.py, 0, point.px, point.py, radius 493 - ); 494 - gradient.addColorStop(0, 'rgba(0, 0, 0, 0.1)'); 495 - gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); 496 - 497 - heatCtx.fillStyle = gradient; 498 - heatCtx.fillRect(point.px - radius, point.py - radius, radius * 2, radius * 2); 499 - }); 500 - 501 - const imageData = heatCtx.getImageData(0, 0, heatCanvas.width, heatCanvas.height); 502 - const data = imageData.data; 503 - 504 - let maxIntensity = 0; 505 - for (let i = 0; i < data.length; i += 4) { 506 - if (data[i + 3] > maxIntensity) maxIntensity = data[i + 3]; 264 + if (this.captureJobs.has(screenKey)) { 265 + return this.captureJobs.get(screenKey); 507 266 } 508 - if (maxIntensity === 0) maxIntensity = 1; 509 267 510 - for (let i = 0; i < data.length; i += 4) { 511 - const intensity = data[i + 3] / maxIntensity; 512 - if (intensity < 0.01) { 513 - data[i] = data[i + 1] = data[i + 2] = data[i + 3] = 0; 514 - continue; 268 + const job = (async () => { 269 + try { 270 + const screenshot = await this.bridge.captureScreenshot(); 271 + this.gazeTracker.setScreenScreenshot(screenKey, screenshot); 272 + return screenshot; 273 + } catch (error) { 274 + console.warn('[UXET] Screenshot capture failed:', error); 275 + this.gazeTracker.markScreenshotFailed(screenKey); 276 + return null; 277 + } finally { 278 + this.captureJobs.delete(screenKey); 515 279 } 280 + })(); 516 281 517 - const [r, g, b] = this.heatmapColor(intensity); 518 - data[i] = r; 519 - data[i + 1] = g; 520 - data[i + 2] = b; 521 - data[i + 3] = Math.round(intensity * 200 + 55); 522 - } 282 + this.captureJobs.set(screenKey, job); 283 + return job; 284 + } 523 285 524 - heatCtx.putImageData(imageData, 0, 0); 525 - ctx.drawImage(heatCanvas, 0, 0); 286 + async renderDebrief(details) { 287 + const stats = this.tracker.getStats(); 288 + const gazeStats = this.gazeTracker.getStats(); 289 + const screens = this.gazeTracker.getScreenRecords(); 526 290 527 - this.elements.heatmapLabel.textContent = `${mappedPoints.length} gaze points / ${this.gazeTracker.getStats().fixations} fixations`; 528 - } 291 + this.elements.debriefTime.textContent = this.session.formatTime(this.session.elapsed); 292 + this.elements.debriefClicks.textContent = stats.mouse.clicks.toLocaleString(); 293 + this.elements.debriefKeys.textContent = stats.keyboard.totalKeys.toLocaleString(); 294 + this.elements.debriefDistance.textContent = Math.round(stats.mouse.distance).toLocaleString(); 295 + this.elements.debriefScrolls.textContent = stats.mouse.scrollEvents.toLocaleString(); 296 + this.elements.debriefVelocity.textContent = stats.mouse.avgVelocity.toLocaleString(); 297 + this.elements.debriefGazePoints.textContent = gazeStats.gazePoints.toLocaleString(); 298 + this.elements.debriefFixations.textContent = gazeStats.fixations.toLocaleString(); 299 + this.elements.debriefFixationDuration.textContent = gazeStats.avgFixationDuration.toLocaleString(); 300 + this.elements.sessionMessage.textContent = `Test finished via ${details?.strategy || 'manual'} completion.`; 529 301 530 - heatmapColor(t) { 531 - if (t < 0.25) { 532 - const f = t / 0.25; 533 - return [0, Math.round(f * 180), Math.round(200 + f * 55)]; 534 - } else if (t < 0.5) { 535 - const f = (t - 0.25) / 0.25; 536 - return [0, Math.round(180 + f * 75), Math.round(255 - f * 255)]; 537 - } else if (t < 0.75) { 538 - const f = (t - 0.5) / 0.25; 539 - return [Math.round(f * 255), 255, 0]; 540 - } else { 541 - const f = (t - 0.75) / 0.25; 542 - return [255, Math.round(255 - f * 255), 0]; 543 - } 302 + this.elements.debriefScreen.classList.remove('hidden'); 303 + await this.debriefRenderer.render(screens); 304 + this.elements.exportBtn.disabled = false; 544 305 } 545 306 546 - formatTime(ms) { 547 - const totalSeconds = Math.floor(ms / 1000); 548 - const minutes = Math.floor(totalSeconds / 60); 549 - const seconds = totalSeconds % 60; 550 - return `${minutes}:${seconds.toString().padStart(2, '0')}`; 307 + exportData() { 308 + const screenRecords = this.gazeTracker.getScreenRecords().map((screen) => ({ 309 + key: screen.key, 310 + title: screen.title, 311 + firstSeenAt: screen.firstSeenAt, 312 + lastSeenAt: screen.lastSeenAt, 313 + documentWidth: screen.document.width, 314 + documentHeight: screen.document.height, 315 + viewportWidth: screen.viewport.width, 316 + viewportHeight: screen.viewport.height, 317 + screenshotCaptured: screen.screenshot.status === 'ready', 318 + screenshotWidth: screen.screenshot.width, 319 + screenshotHeight: screen.screenshot.height, 320 + gazePointCount: screen.gazePoints.length, 321 + fixationCount: screen.fixationCount 322 + })); 323 + 324 + this.session.exportSession({ 325 + gaze: this.gazeTracker.exportData(), 326 + interactions: this.tracker.exportData(), 327 + screens: screenRecords 328 + }); 551 329 } 552 330 553 - async resetSession() { 554 - this.teardownWinCondition(); 555 - this.session.reset(); 331 + async resetRuntimeOnly() { 332 + this.winConditions.stop(); 333 + this.tracker.detach(); 556 334 this.tracker.reset(); 335 + this.bridge.detach(); 336 + this.captureJobs.clear(); 557 337 this.gazeTracker.reset(); 338 + this.elements.exportBtn.disabled = true; 339 + this.elements.gallery.innerHTML = ''; 340 + this.elements.galleryLabel.textContent = 'Heatmaps will appear here after a test completes.'; 341 + this.elements.debriefScreen.classList.add('hidden'); 342 + this.elements.calibrationScreen.classList.add('hidden'); 343 + this.elements.startTestBtn.classList.add('hidden'); 558 344 559 345 if (this.gazeInitialized) { 560 346 await this.gazeTracker.end(); 561 347 this.gazeInitialized = false; 562 348 } 349 + } 563 350 564 - this.calibrationClicks = {}; 565 - this.calibratedPoints = 0; 566 - 351 + async resetSession() { 352 + await this.resetRuntimeOnly(); 353 + this.session.reset(); 354 + this.selectedApp = null; 355 + this.calibrationPassed = false; 356 + this.debugOverride = false; 357 + this.elements.appSelect.value = ''; 358 + this.elements.currentTaskText.textContent = 'None'; 567 359 this.elements.iframe.src = ''; 568 360 this.elements.iframe.classList.add('hidden'); 569 361 this.elements.iframePlaceholder.classList.remove('hidden'); 570 - 571 - this.elements.calibrationScreen.classList.add('hidden'); 572 - this.elements.debriefScreen.classList.add('hidden'); 573 - this.elements.currentTaskText.textContent = 'None'; 574 - this.elements.appSelect.value = ''; 575 - this.selectedApp = null; 576 - this.currentTask = ''; 577 - 578 - for (let i = 1; i <= this.TOTAL_POINTS; i++) { 579 - const point = document.getElementById(`cal-pt-${i}`); 580 - if (point) { 581 - point.setAttribute('data-clicks', '0'); 582 - point.classList.remove('done'); 583 - point.style.pointerEvents = ''; 584 - } 585 - } 586 - 587 - const ctx = this.elements.heatmapCanvas.getContext('2d'); 588 - ctx.clearRect(0, 0, this.elements.heatmapCanvas.width, this.elements.heatmapCanvas.height); 589 - this.elements.heatmapLabel.textContent = 'Gaze density overlay'; 362 + this.elements.sessionMessage.textContent = 'Select an app to begin.'; 363 + this.elements.debugPanel.classList.add('hidden'); 590 364 } 591 365 592 - exportData() { 593 - const trackerData = this.tracker.exportData(); 594 - trackerData.gaze = this.gazeTracker.exportData(); 595 - this.session.exportSession(trackerData); 366 + failSession(message) { 367 + this.session.setState('error', { errorMessage: message }); 368 + this.elements.sessionMessage.textContent = message; 596 369 } 597 370 598 - updateSessionStatus(status) { 599 - this.elements.sessionStatus.textContent = status.charAt(0).toUpperCase() + status.slice(1); 371 + updateUiForState(state, details = {}) { 372 + this.elements.sessionStatus.textContent = state.replace(/_/g, ' '); 373 + this.elements.exportBtn.disabled = state !== 'complete'; 374 + 375 + const showCalibration = state === 'calibrating' || state === 'ready_to_start'; 376 + this.elements.calibrationScreen.classList.toggle('hidden', !showCalibration); 377 + this.elements.startTestBtn.classList.toggle('hidden', state !== 'ready_to_start'); 378 + this.elements.debugEndBtn.disabled = state !== 'recording'; 379 + 380 + switch (state) { 381 + case 'idle': 382 + this.elements.sessionMessage.textContent = 'Select an app to begin.'; 383 + break; 384 + case 'loading_app': 385 + this.elements.sessionMessage.textContent = 'Loading app and attaching instrumentation bridge...'; 386 + break; 387 + case 'calibrating': 388 + this.elements.sessionMessage.textContent = 'Complete guided calibration before recording starts.'; 389 + break; 390 + case 'ready_to_start': 391 + this.elements.sessionMessage.textContent = this.debugOverride 392 + ? 'Calibration skipped in debug mode. Recording can start.' 393 + : 'Calibration passed. Start the test when the participant is ready.'; 394 + break; 395 + case 'recording': 396 + this.elements.calibrationScreen.classList.add('hidden'); 397 + this.elements.sessionMessage.textContent = 'Recording interactions, gaze, and page state.'; 398 + break; 399 + case 'finishing': 400 + this.elements.sessionMessage.textContent = 'Finalizing screenshots and rendering debrief...'; 401 + break; 402 + case 'complete': 403 + if (!this.elements.sessionMessage.textContent) { 404 + this.elements.sessionMessage.textContent = 'Debrief ready.'; 405 + } 406 + break; 407 + case 'error': 408 + this.elements.sessionMessage.textContent = details.errorMessage || 'The session entered an error state.'; 409 + break; 410 + } 600 411 } 601 412 } 602 413
+64 -30
js/session.js
··· 1 1 /** 2 - * UXET Session - Session management for testing sessions 2 + * UXET Session - lifecycle state and timer management 3 3 */ 4 4 5 + const VALID_STATES = new Set([ 6 + 'idle', 7 + 'loading_app', 8 + 'calibrating', 9 + 'ready_to_start', 10 + 'recording', 11 + 'finishing', 12 + 'complete', 13 + 'error' 14 + ]); 15 + 5 16 export class Session { 6 17 constructor() { 7 - this.status = 'idle'; // idle, recording, stopped 18 + this.state = 'idle'; 8 19 this.startTime = null; 9 20 this.elapsed = 0; 10 21 this.timerInterval = null; 11 22 this.appName = ''; 12 23 this.task = ''; 13 - this.onStatusChange = null; 24 + this.errorMessage = ''; 25 + this.onStateChange = null; 14 26 this.onTimerUpdate = null; 15 27 } 16 28 17 - start() { 18 - if (this.status === 'recording') return; 29 + setState(nextState, details = {}) { 30 + if (!VALID_STATES.has(nextState)) { 31 + throw new Error(`Invalid session state: ${nextState}`); 32 + } 33 + 34 + this.state = nextState; 35 + if (details.errorMessage) { 36 + this.errorMessage = details.errorMessage; 37 + } else if (nextState !== 'error') { 38 + this.errorMessage = ''; 39 + } 40 + 41 + this.notifyStateChange(details); 42 + } 43 + 44 + startRecording() { 45 + if (this.state === 'recording') { 46 + return; 47 + } 19 48 20 - this.status = 'recording'; 21 - this.startTime = Date.now() - this.elapsed; 49 + this.startTime = Date.now(); 50 + this.elapsed = 0; 51 + this.setState('recording'); 22 52 23 - this.timerInterval = setInterval(() => { 53 + this.timerInterval = window.setInterval(() => { 24 54 this.elapsed = Date.now() - this.startTime; 25 55 if (this.onTimerUpdate) { 26 56 this.onTimerUpdate(this.formatTime(this.elapsed)); 27 57 } 28 58 }, 100); 29 - 30 - this.notifyStatusChange(); 31 59 } 32 60 33 - stop() { 34 - if (this.status !== 'recording') return; 35 - 36 - this.status = 'stopped'; 37 - this.elapsed = Date.now() - this.startTime; 61 + stopRecording(nextState = 'complete') { 62 + if (this.startTime) { 63 + this.elapsed = Date.now() - this.startTime; 64 + } 38 65 39 66 if (this.timerInterval) { 40 67 clearInterval(this.timerInterval); 41 68 this.timerInterval = null; 42 69 } 43 70 44 - this.notifyStatusChange(); 71 + this.setState(nextState); 72 + 73 + if (this.onTimerUpdate) { 74 + this.onTimerUpdate(this.formatTime(this.elapsed)); 75 + } 45 76 } 46 77 47 78 reset() { 48 - this.status = 'idle'; 49 - this.startTime = null; 50 - this.elapsed = 0; 51 - this.task = ''; 52 - 53 79 if (this.timerInterval) { 54 80 clearInterval(this.timerInterval); 55 81 this.timerInterval = null; 56 82 } 57 83 84 + this.state = 'idle'; 85 + this.startTime = null; 86 + this.elapsed = 0; 87 + this.task = ''; 88 + this.appName = ''; 89 + this.errorMessage = ''; 90 + 58 91 if (this.onTimerUpdate) { 59 92 this.onTimerUpdate('00:00:00'); 60 93 } 61 94 62 - this.notifyStatusChange(); 95 + this.notifyStateChange(); 63 96 } 64 97 65 98 setAppName(name) { ··· 77 110 const seconds = totalSeconds % 60; 78 111 79 112 return [hours, minutes, seconds] 80 - .map(n => n.toString().padStart(2, '0')) 113 + .map((n) => n.toString().padStart(2, '0')) 81 114 .join(':'); 82 115 } 83 116 84 - notifyStatusChange() { 85 - if (this.onStatusChange) { 86 - this.onStatusChange(this.status); 117 + notifyStateChange(details = {}) { 118 + if (this.onStateChange) { 119 + this.onStateChange(this.state, details); 87 120 } 88 121 } 89 122 ··· 91 124 return { 92 125 appName: this.appName, 93 126 task: this.task, 94 - status: this.status, 127 + status: this.state, 95 128 startTime: this.startTime ? new Date(this.startTime).toISOString() : null, 96 - duration: this.elapsed 129 + duration: this.elapsed, 130 + errorMessage: this.errorMessage || null 97 131 }; 98 132 } 99 133 100 - exportSession(trackerData) { 134 + exportSession(payload) { 101 135 const data = { 102 136 session: this.getMetadata(), 103 - ...trackerData 137 + ...payload 104 138 }; 105 139 106 140 const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
+134 -165
js/tracker.js
··· 1 1 /** 2 - * UXET Tracker - Core tracking engine for mouse and keyboard events 2 + * UXET Tracker - interaction event tracking with screen-aware metadata 3 3 */ 4 4 5 + function createInitialStats() { 6 + return { 7 + mouse: { 8 + x: 0, 9 + y: 0, 10 + movements: 0, 11 + clicks: 0, 12 + distance: 0, 13 + avgVelocity: 0, 14 + scrollEvents: 0 15 + }, 16 + keyboard: { 17 + totalKeys: 0, 18 + keysPerMinute: 0, 19 + lastKey: '', 20 + backspaces: 0 21 + }, 22 + totalEvents: 0 23 + }; 24 + } 25 + 5 26 export class Tracker { 6 27 constructor() { 7 28 this.isRecording = false; 8 29 this.events = []; 9 - this.stats = this.createInitialStats(); 10 - this.iframeDoc = null; 30 + this.stats = createInitialStats(); 31 + this.bridge = null; 32 + this.detachFns = []; 11 33 this.lastMousePos = null; 12 34 this.lastMouseTime = null; 13 35 this.velocities = []; 14 - this.listeners = new Map(); 15 - this.onStatsUpdate = null; 16 36 this.onEvent = null; 17 37 } 18 38 19 - createInitialStats() { 20 - return { 21 - mouse: { 22 - x: 0, 23 - y: 0, 24 - movements: 0, 25 - clicks: 0, 26 - distance: 0, 27 - avgVelocity: 0, 28 - scrollEvents: 0 29 - }, 30 - keyboard: { 31 - totalKeys: 0, 32 - keysPerMinute: 0, 33 - lastKey: '', 34 - backspaces: 0 35 - }, 36 - totalEvents: 0 37 - }; 38 - } 39 - 40 - reset() { 41 - this.events = []; 42 - this.stats = this.createInitialStats(); 43 - this.lastMousePos = null; 44 - this.lastMouseTime = null; 45 - this.velocities = []; 46 - this.notifyStatsUpdate(); 47 - } 48 - 49 - attachToIframe(iframe) { 39 + attach(bridge) { 50 40 this.detach(); 51 - 52 - try { 53 - this.iframeDoc = iframe.contentDocument || iframe.contentWindow.document; 54 - if (!this.iframeDoc) { 55 - console.warn('Cannot access iframe document - same-origin policy'); 56 - return false; 57 - } 58 - 59 - // Attach event listeners 60 - this.addListener(this.iframeDoc, 'mousemove', this.handleMouseMove.bind(this)); 61 - this.addListener(this.iframeDoc, 'click', this.handleClick.bind(this)); 62 - this.addListener(this.iframeDoc, 'scroll', this.handleScroll.bind(this), true); 63 - this.addListener(this.iframeDoc, 'keydown', this.handleKeyDown.bind(this)); 64 - this.addListener(this.iframeDoc, 'wheel', this.handleScroll.bind(this)); 65 - 66 - // Also track on scrollable elements within the iframe 67 - const scrollables = this.iframeDoc.querySelectorAll('*'); 68 - scrollables.forEach(el => { 69 - if (el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth) { 70 - this.addListener(el, 'scroll', this.handleScroll.bind(this)); 71 - } 72 - }); 73 - 74 - return true; 75 - } catch (e) { 76 - console.error('Error attaching to iframe:', e); 41 + this.bridge = bridge; 42 + const doc = bridge.iframeDocument; 43 + const win = bridge.iframeWindow; 44 + if (!doc || !win) { 77 45 return false; 78 46 } 47 + 48 + this.addListener(doc, 'mousemove', (event) => this.handleMouseMove(event), { capture: true, passive: true }); 49 + this.addListener(doc, 'click', (event) => this.handleClick(event), true); 50 + this.addListener(doc, 'keydown', (event) => this.handleKeyDown(event), true); 51 + this.addListener(win, 'scroll', (event) => this.handleScroll(event), { passive: true }); 52 + this.addListener(doc, 'scroll', (event) => this.handleScroll(event), { capture: true, passive: true }); 53 + return true; 79 54 } 80 55 81 - addListener(target, event, handler, options = false) { 82 - target.addEventListener(event, handler, options); 83 - const key = `${target.nodeName || 'doc'}-${event}`; 84 - if (!this.listeners.has(key)) { 85 - this.listeners.set(key, []); 86 - } 87 - this.listeners.get(key).push({ target, event, handler, options }); 56 + addListener(target, eventName, handler, options = false) { 57 + target.addEventListener(eventName, handler, options); 58 + this.detachFns.push(() => target.removeEventListener(eventName, handler, options)); 88 59 } 89 60 90 61 detach() { 91 - this.listeners.forEach(entries => { 92 - entries.forEach(({ target, event, handler, options }) => { 93 - try { 94 - target.removeEventListener(event, handler, options); 95 - } catch (e) { 96 - // Target may no longer exist 97 - } 98 - }); 99 - }); 100 - this.listeners.clear(); 101 - this.iframeDoc = null; 62 + this.detachFns.forEach((fn) => fn()); 63 + this.detachFns = []; 64 + this.bridge = null; 102 65 } 103 66 104 67 start() { ··· 110 73 this.isRecording = false; 111 74 } 112 75 113 - handleMouseMove(e) { 114 - if (!this.isRecording) return; 76 + reset() { 77 + this.stop(); 78 + this.events = []; 79 + this.stats = createInitialStats(); 80 + this.lastMousePos = null; 81 + this.lastMouseTime = null; 82 + this.velocities = []; 83 + } 84 + 85 + handleMouseMove(event) { 86 + if (!this.isRecording) { 87 + return; 88 + } 115 89 116 90 const now = Date.now(); 117 - const x = e.clientX; 118 - const y = e.clientY; 91 + const metrics = this.bridge.getMetricsSnapshot(); 92 + const iframeX = Math.round(event.clientX); 93 + const iframeY = Math.round(event.clientY); 94 + const docX = iframeX + metrics.scrollX; 95 + const docY = iframeY + metrics.scrollY; 119 96 120 - // Calculate distance and velocity 121 97 if (this.lastMousePos) { 122 - const dx = x - this.lastMousePos.x; 123 - const dy = y - this.lastMousePos.y; 124 - const distance = Math.sqrt(dx * dx + dy * dy); 98 + const distance = Math.hypot(docX - this.lastMousePos.x, docY - this.lastMousePos.y); 125 99 this.stats.mouse.distance += distance; 126 - 127 100 if (this.lastMouseTime) { 128 - const dt = (now - this.lastMouseTime) / 1000; // seconds 129 - if (dt > 0) { 130 - const velocity = distance / dt; 131 - this.velocities.push(velocity); 132 - // Keep last 50 velocities for average 101 + const seconds = (now - this.lastMouseTime) / 1000; 102 + if (seconds > 0) { 103 + this.velocities.push(distance / seconds); 133 104 if (this.velocities.length > 50) { 134 105 this.velocities.shift(); 135 106 } 136 107 this.stats.mouse.avgVelocity = Math.round( 137 - this.velocities.reduce((a, b) => a + b, 0) / this.velocities.length 108 + this.velocities.reduce((sum, value) => sum + value, 0) / this.velocities.length 138 109 ); 139 110 } 140 111 } 141 112 } 142 113 143 - this.lastMousePos = { x, y }; 114 + this.lastMousePos = { x: docX, y: docY }; 144 115 this.lastMouseTime = now; 145 - 146 - this.stats.mouse.x = x; 147 - this.stats.mouse.y = y; 148 - this.stats.mouse.movements++; 149 - this.stats.totalEvents++; 116 + this.stats.mouse.x = docX; 117 + this.stats.mouse.y = docY; 118 + this.stats.mouse.movements += 1; 119 + this.stats.totalEvents += 1; 150 120 151 - // Throttle event logging for mouse moves (every 10th move) 152 - if (this.stats.mouse.movements % 10 === 0) { 153 - this.logEvent('mouse', `Move to (${x}, ${y})`); 121 + if (this.stats.mouse.movements % 12 === 0) { 122 + this.logEvent('mouse', 'mousemove', event, metrics, { docX, docY }); 154 123 } 155 - 156 - this.notifyStatsUpdate(); 157 124 } 158 125 159 - handleClick(e) { 160 - if (!this.isRecording) return; 126 + handleClick(event) { 127 + if (!this.isRecording) { 128 + return; 129 + } 130 + const metrics = this.bridge.getMetricsSnapshot(); 131 + this.stats.mouse.clicks += 1; 132 + this.stats.totalEvents += 1; 133 + const target = event.target; 134 + const tagName = target?.tagName?.toLowerCase() || 'unknown'; 135 + const descriptor = [ 136 + tagName, 137 + target?.id ? `#${target.id}` : '', 138 + target?.className && typeof target.className === 'string' 139 + ? `.${target.className.split(' ').filter(Boolean)[0] || ''}` 140 + : '' 141 + ].join(''); 161 142 162 - this.stats.mouse.clicks++; 163 - this.stats.totalEvents++; 164 - 165 - const target = e.target; 166 - const tagName = target.tagName.toLowerCase(); 167 - const id = target.id ? `#${target.id}` : ''; 168 - const className = target.className ? `.${target.className.split(' ')[0]}` : ''; 169 - 170 - this.logEvent('click', `Click on ${tagName}${id}${className} at (${e.clientX}, ${e.clientY})`); 171 - this.notifyStatsUpdate(); 143 + this.logEvent('click', `click:${descriptor}`, event, metrics, { 144 + docX: Math.round(event.clientX + metrics.scrollX), 145 + docY: Math.round(event.clientY + metrics.scrollY) 146 + }); 172 147 } 173 148 174 - handleScroll(e) { 175 - if (!this.isRecording) return; 176 - 177 - this.stats.mouse.scrollEvents++; 178 - this.stats.totalEvents++; 179 - 180 - this.logEvent('mouse', 'Scroll event'); 181 - this.notifyStatsUpdate(); 149 + handleScroll() { 150 + if (!this.isRecording) { 151 + return; 152 + } 153 + const metrics = this.bridge.getMetricsSnapshot(); 154 + this.stats.mouse.scrollEvents += 1; 155 + this.stats.totalEvents += 1; 156 + this.logEvent('scroll', 'scroll', null, metrics); 182 157 } 183 158 184 - handleKeyDown(e) { 185 - if (!this.isRecording) return; 186 - 187 - this.stats.keyboard.totalKeys++; 188 - this.stats.totalEvents++; 189 - 190 - // Calculate keys per minute 191 - const elapsed = (Date.now() - this.startTime) / 60000; // minutes 192 - if (elapsed > 0) { 193 - this.stats.keyboard.keysPerMinute = Math.round(this.stats.keyboard.totalKeys / elapsed); 159 + handleKeyDown(event) { 160 + if (!this.isRecording) { 161 + return; 194 162 } 195 - 196 - // Track backspaces 197 - if (e.key === 'Backspace') { 198 - this.stats.keyboard.backspaces++; 163 + const metrics = this.bridge.getMetricsSnapshot(); 164 + this.stats.keyboard.totalKeys += 1; 165 + this.stats.totalEvents += 1; 166 + const elapsedMinutes = (Date.now() - this.startTime) / 60000; 167 + if (elapsedMinutes > 0) { 168 + this.stats.keyboard.keysPerMinute = Math.round(this.stats.keyboard.totalKeys / elapsedMinutes); 199 169 } 200 - 201 - // Display key (handle special keys) 202 - let keyDisplay = e.key; 203 - if (e.key === ' ') keyDisplay = 'Space'; 204 - if (e.key.length > 1) keyDisplay = `[${e.key}]`; 205 - 206 - this.stats.keyboard.lastKey = keyDisplay; 207 - 208 - this.logEvent('key', `Key: ${keyDisplay}`); 209 - this.notifyStatsUpdate(); 170 + if (event.key === 'Backspace') { 171 + this.stats.keyboard.backspaces += 1; 172 + } 173 + this.stats.keyboard.lastKey = event.key.length > 1 ? `[${event.key}]` : event.key; 174 + this.logEvent('key', `key:${this.stats.keyboard.lastKey}`, event, metrics); 210 175 } 211 176 212 - logEvent(type, message) { 213 - const event = { 177 + logEvent(type, message, event, metrics, extra = {}) { 178 + const payload = { 214 179 timestamp: Date.now(), 215 180 type, 216 181 message, 217 - stats: { ...this.stats } 182 + screenKey: metrics?.key || 'unknown', 183 + scrollX: metrics?.scrollX || 0, 184 + scrollY: metrics?.scrollY || 0, 185 + viewportWidth: metrics?.viewportWidth || 0, 186 + viewportHeight: metrics?.viewportHeight || 0, 187 + documentWidth: metrics?.documentWidth || 0, 188 + documentHeight: metrics?.documentHeight || 0, 189 + targetTag: event?.target?.tagName?.toLowerCase() || null, 190 + ...extra 218 191 }; 219 - this.events.push(event); 220 192 193 + this.events.push(payload); 221 194 if (this.onEvent) { 222 - this.onEvent(event); 195 + this.onEvent(payload); 223 196 } 224 197 } 225 198 226 - notifyStatsUpdate() { 227 - if (this.onStatsUpdate) { 228 - this.onStatsUpdate({ ...this.stats }); 229 - } 199 + getStats() { 200 + return { 201 + mouse: { ...this.stats.mouse }, 202 + keyboard: { ...this.stats.keyboard }, 203 + totalEvents: this.stats.totalEvents 204 + }; 230 205 } 231 206 232 207 getEvents() { 233 208 return [...this.events]; 234 209 } 235 210 236 - getStats() { 237 - return { ...this.stats }; 238 - } 239 - 240 211 exportData() { 241 212 return { 242 - exportedAt: new Date().toISOString(), 243 - duration: this.startTime ? Date.now() - this.startTime : 0, 244 213 stats: this.getStats(), 245 214 events: this.getEvents() 246 215 };
+156
js/winConditions.js
··· 1 + class IntervalEvaluator { 2 + constructor(intervalMs, callback) { 3 + this.intervalMs = intervalMs; 4 + this.callback = callback; 5 + this.intervalId = null; 6 + } 7 + 8 + start(context) { 9 + this.stop(); 10 + this.intervalId = window.setInterval(() => { 11 + this.callback(context); 12 + }, this.intervalMs); 13 + } 14 + 15 + stop() { 16 + if (this.intervalId) { 17 + window.clearInterval(this.intervalId); 18 + this.intervalId = null; 19 + } 20 + } 21 + } 22 + 23 + function createSelectorEvaluator(selector) { 24 + return new IntervalEvaluator(250, ({ bridge, complete, session }) => { 25 + if (session.state !== 'recording') { 26 + return; 27 + } 28 + try { 29 + const doc = bridge.iframeDocument; 30 + const element = doc?.querySelector(selector); 31 + if (!element) { 32 + return; 33 + } 34 + const style = bridge.iframeWindow.getComputedStyle(element); 35 + const visible = style.display !== 'none' && 36 + style.visibility !== 'hidden' && 37 + parseFloat(style.opacity || '1') > 0 && 38 + element.offsetWidth > 0 && 39 + element.offsetHeight > 0; 40 + if (visible) { 41 + complete({ strategy: 'selector', selector }); 42 + } 43 + } catch (error) { 44 + console.warn('[WinCondition][selector]', error); 45 + } 46 + }); 47 + } 48 + 49 + function createTextEvaluator(text) { 50 + return new IntervalEvaluator(400, ({ bridge, complete, session }) => { 51 + if (session.state !== 'recording') { 52 + return; 53 + } 54 + try { 55 + const bodyText = bridge.iframeDocument?.body?.innerText || ''; 56 + if (bodyText.includes(text)) { 57 + complete({ strategy: 'text', matchedText: text }); 58 + } 59 + } catch (error) { 60 + console.warn('[WinCondition][text]', error); 61 + } 62 + }); 63 + } 64 + 65 + function createUrlEvaluator(pattern) { 66 + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); 67 + const regex = new RegExp(`^${escaped}$`, 'i'); 68 + return new IntervalEvaluator(300, ({ bridge, complete, session }) => { 69 + if (session.state !== 'recording') { 70 + return; 71 + } 72 + try { 73 + const href = bridge.iframeWindow?.location?.href || ''; 74 + if (regex.test(href)) { 75 + complete({ strategy: 'url', url: href }); 76 + } 77 + } catch (error) { 78 + console.warn('[WinCondition][url]', error); 79 + } 80 + }); 81 + } 82 + 83 + function createPostMessageEvaluator() { 84 + return { 85 + handler: null, 86 + start({ complete }) { 87 + this.stop(); 88 + this.handler = (event) => { 89 + if (event.data?.type === 'UXET_TASK_COMPLETE') { 90 + complete({ strategy: 'postMessage' }); 91 + } 92 + }; 93 + window.addEventListener('message', this.handler); 94 + }, 95 + stop() { 96 + if (this.handler) { 97 + window.removeEventListener('message', this.handler); 98 + this.handler = null; 99 + } 100 + } 101 + }; 102 + } 103 + 104 + export class WinConditionRegistry { 105 + constructor() { 106 + this.activeEvaluator = null; 107 + } 108 + 109 + start(spec, context) { 110 + this.stop(); 111 + if (!spec) { 112 + return; 113 + } 114 + 115 + const evaluator = this.createEvaluator(spec); 116 + if (!evaluator) { 117 + console.warn('[WinCondition] Unknown win condition:', spec); 118 + return; 119 + } 120 + 121 + this.activeEvaluator = evaluator; 122 + evaluator.start(context); 123 + } 124 + 125 + stop() { 126 + if (this.activeEvaluator) { 127 + this.activeEvaluator.stop(); 128 + this.activeEvaluator = null; 129 + } 130 + } 131 + 132 + createEvaluator(spec) { 133 + if (spec === 'postMessage') { 134 + return createPostMessageEvaluator(); 135 + } 136 + 137 + const separatorIndex = spec.indexOf(':'); 138 + if (separatorIndex === -1) { 139 + return null; 140 + } 141 + 142 + const strategy = spec.slice(0, separatorIndex); 143 + const value = spec.slice(separatorIndex + 1); 144 + 145 + switch (strategy) { 146 + case 'selector': 147 + return createSelectorEvaluator(value); 148 + case 'text': 149 + return createTextEvaluator(value); 150 + case 'url': 151 + return createUrlEvaluator(value); 152 + default: 153 + return null; 154 + } 155 + } 156 + }
+127
scripts/server-tui.sh
··· 1 + #!/usr/bin/env bash 2 + 3 + set -u 4 + 5 + ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 6 + DEFAULT_PORT="${UXET_PORT:-8080}" 7 + PID_FILE="$ROOT_DIR/.uxet-server.pid" 8 + LOG_FILE="$ROOT_DIR/.uxet-server.log" 9 + 10 + server_url() { 11 + printf 'http://127.0.0.1:%s\n' "$1" 12 + } 13 + 14 + read_port() { 15 + local input 16 + read -r -p "Port [$DEFAULT_PORT]: " input 17 + if [[ -n "${input:-}" ]]; then 18 + DEFAULT_PORT="$input" 19 + fi 20 + } 21 + 22 + server_running() { 23 + if [[ ! -f "$PID_FILE" ]]; then 24 + return 1 25 + fi 26 + 27 + local pid 28 + pid="$(<"$PID_FILE")" 29 + [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null 30 + } 31 + 32 + start_server() { 33 + if server_running; then 34 + printf 'Server already running at %s (pid %s)\n' "$(server_url "$DEFAULT_PORT")" "$(cat "$PID_FILE")" 35 + return 36 + fi 37 + 38 + read_port 39 + 40 + printf 'Starting UXET server on %s\n' "$(server_url "$DEFAULT_PORT")" 41 + ( 42 + cd "$ROOT_DIR" && 43 + nohup python3 -m http.server "$DEFAULT_PORT" >"$LOG_FILE" 2>&1 & 44 + echo $! >"$PID_FILE" 45 + ) 46 + 47 + sleep 1 48 + 49 + if server_running; then 50 + printf 'Server started. Log: %s\n' "$LOG_FILE" 51 + else 52 + printf 'Failed to start server. Check %s\n' "$LOG_FILE" 53 + rm -f "$PID_FILE" 54 + fi 55 + } 56 + 57 + stop_server() { 58 + if ! server_running; then 59 + printf 'Server is not running.\n' 60 + rm -f "$PID_FILE" 61 + return 62 + fi 63 + 64 + local pid 65 + pid="$(<"$PID_FILE")" 66 + printf 'Stopping server pid %s\n' "$pid" 67 + kill "$pid" 2>/dev/null || true 68 + 69 + for _ in 1 2 3 4 5; do 70 + if ! kill -0 "$pid" 2>/dev/null; then 71 + break 72 + fi 73 + sleep 1 74 + done 75 + 76 + if kill -0 "$pid" 2>/dev/null; then 77 + printf 'Server did not exit cleanly, sending SIGKILL.\n' 78 + kill -9 "$pid" 2>/dev/null || true 79 + fi 80 + 81 + rm -f "$PID_FILE" 82 + printf 'Server stopped.\n' 83 + } 84 + 85 + show_status() { 86 + if server_running; then 87 + printf 'Status: running\n' 88 + printf 'PID: %s\n' "$(cat "$PID_FILE")" 89 + printf 'URL: %s\n' "$(server_url "$DEFAULT_PORT")" 90 + printf 'Log: %s\n' "$LOG_FILE" 91 + else 92 + printf 'Status: stopped\n' 93 + printf 'Default URL: %s\n' "$(server_url "$DEFAULT_PORT")" 94 + fi 95 + } 96 + 97 + show_log_tail() { 98 + if [[ ! -f "$LOG_FILE" ]]; then 99 + printf 'No log file yet.\n' 100 + return 101 + fi 102 + 103 + printf '\nLast 20 log lines:\n\n' 104 + tail -n 20 "$LOG_FILE" 105 + printf '\n' 106 + } 107 + 108 + main_menu() { 109 + while true; do 110 + printf '\nUXET Server Control\n' 111 + printf 'Repo: %s\n\n' "$ROOT_DIR" 112 + 113 + PS3="Choose an action: " 114 + select action in "Start server" "Stop server" "Status" "Show log tail" "Quit"; do 115 + case "$REPLY" in 116 + 1) start_server; break ;; 117 + 2) stop_server; break ;; 118 + 3) show_status; break ;; 119 + 4) show_log_tail; break ;; 120 + 5) exit 0 ;; 121 + *) printf 'Invalid choice.\n'; break ;; 122 + esac 123 + done 124 + done 125 + } 126 + 127 + main_menu
+134
testable-apps/long-page-app/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>Long Page Demo</title> 8 + <style> 9 + * { box-sizing: border-box; } 10 + body { 11 + margin: 0; 12 + font-family: Georgia, serif; 13 + color: #1f1a17; 14 + background: #faf6ef; 15 + } 16 + header { 17 + position: sticky; 18 + top: 0; 19 + z-index: 10; 20 + background: rgba(255, 249, 240, 0.92); 21 + backdrop-filter: blur(8px); 22 + border-bottom: 1px solid rgba(31, 26, 23, 0.12); 23 + padding: 18px 24px; 24 + } 25 + header h1 { margin: 0 0 6px; } 26 + main { 27 + max-width: 980px; 28 + margin: 0 auto; 29 + padding: 24px; 30 + } 31 + section { 32 + min-height: 92vh; 33 + border: 1px solid rgba(31, 26, 23, 0.12); 34 + border-radius: 24px; 35 + background: white; 36 + margin-bottom: 24px; 37 + padding: 28px; 38 + box-shadow: 0 18px 40px rgba(47, 36, 22, 0.08); 39 + } 40 + .cards { 41 + display: grid; 42 + grid-template-columns: repeat(3, minmax(0, 1fr)); 43 + gap: 16px; 44 + margin-top: 20px; 45 + } 46 + .card { 47 + border-radius: 18px; 48 + padding: 18px; 49 + background: linear-gradient(180deg, #fef8ef, #f4e8d8); 50 + min-height: 160px; 51 + } 52 + button { 53 + padding: 14px 18px; 54 + border: none; 55 + border-radius: 999px; 56 + background: #9b3d25; 57 + color: white; 58 + font: inherit; 59 + cursor: pointer; 60 + } 61 + #success-banner { 62 + display: none; 63 + margin-top: 18px; 64 + padding: 16px; 65 + border-radius: 16px; 66 + background: #276749; 67 + color: white; 68 + font-weight: 700; 69 + } 70 + @media (max-width: 720px) { 71 + .cards { grid-template-columns: 1fr; } 72 + } 73 + </style> 74 + </head> 75 + 76 + <body> 77 + <header> 78 + <h1>Long Page Scroll Demo</h1> 79 + <p>Use this to validate that UXET keeps gaze points aligned as the page scrolls.</p> 80 + </header> 81 + 82 + <main> 83 + <section> 84 + <h2>Overview</h2> 85 + <p>This first section is intentionally roomy so gaze landing areas spread out over a larger scroll surface.</p> 86 + <div class="cards"> 87 + <div class="card">Pricing comparison</div> 88 + <div class="card">Feature matrix</div> 89 + <div class="card">Research summary</div> 90 + </div> 91 + </section> 92 + 93 + <section> 94 + <h2>Comparison</h2> 95 + <p>Read through the options, scan the cards, and continue toward the bottom of the page.</p> 96 + <div class="cards"> 97 + <div class="card">Option A: best onboarding</div> 98 + <div class="card">Option B: strongest analytics</div> 99 + <div class="card">Option C: lowest cost</div> 100 + </div> 101 + </section> 102 + 103 + <section> 104 + <h2>Decision Notes</h2> 105 + <p>This section gives more vertical space for testing scroll-aware heatmap projection.</p> 106 + <div class="cards"> 107 + <div class="card">Implementation risk notes</div> 108 + <div class="card">Support expectations</div> 109 + <div class="card">Rollout concerns</div> 110 + </div> 111 + </section> 112 + 113 + <section> 114 + <h2>Subscribe</h2> 115 + <p>Finish the task by subscribing below.</p> 116 + <form id="subscribe-form"> 117 + <label for="email">Email</label> 118 + <input id="email" type="email" required placeholder="name@example.com" 119 + style="display:block;width:min(420px,100%);margin:10px 0 18px;padding:14px 16px;border:1px solid rgba(31,26,23,0.2);border-radius:14px;"> 120 + <button type="submit">Subscribe</button> 121 + </form> 122 + <div id="success-banner">Subscription complete. Thanks for reviewing the full page.</div> 123 + </section> 124 + </main> 125 + 126 + <script> 127 + document.getElementById('subscribe-form').addEventListener('submit', (event) => { 128 + event.preventDefault(); 129 + document.getElementById('success-banner').style.display = 'block'; 130 + }); 131 + </script> 132 + </body> 133 + 134 + </html>