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.

abysmal

+1222 -1232
+188
README.md
··· 1 + # UXET — UX Eye-Tracking Testing Framework 2 + 3 + UXET is a browser-based UX testing framework with integrated **WebGazer.js** eye tracking. Load any web app into a sandboxed iframe, define a task and a win condition, and UXET records mouse, keyboard, scroll, and gaze data — generating a heatmap when the test completes. 4 + 5 + --- 6 + 7 + ## Quick Start 8 + 9 + ```bash 10 + # Serve the project root (any static server works) 11 + npx http-server . -p 8080 12 + 13 + # Open http://localhost:8080 14 + ``` 15 + 16 + 1. Select an app from the dropdown 17 + 2. Click **Load** 18 + 3. Complete the 9-point eye-tracking calibration (or skip it with the invisible debug button at the top-right corner) 19 + 4. Read the task briefing, then click **Begin Testing** 20 + 5. Interact with the app — the test auto-stops when the **win condition** is met 21 + 6. View the debrief screen with stats and a gaze heatmap 22 + 23 + --- 24 + 25 + ## Adding a Test App 26 + 27 + ### 1. Create the app 28 + 29 + Place your app inside `testable-apps/`: 30 + 31 + ``` 32 + testable-apps/ 33 + my-app/ 34 + index.html 35 + ``` 36 + 37 + Your app is a normal HTML page. **No UXET-specific code is required.** 38 + 39 + ### 2. Register it in the dropdown 40 + 41 + Open `index.html` and add a `<div class="dropdown-item">` inside `#dropdown-menu`: 42 + 43 + ```html 44 + <div class="dropdown-item" 45 + data-value="testable-apps/my-app/index.html" 46 + data-task="Complete the signup flow" 47 + data-win="selector:.signup-success"> 48 + <span class="item-name">My App</span> 49 + <span class="item-task">Sign up</span> 50 + </div> 51 + ``` 52 + 53 + | Attribute | Description | 54 + |---|---| 55 + | `data-value` | Path to the app's entry HTML file | 56 + | `data-task` | Task description shown to the test participant | 57 + | `data-win` | Win condition that ends the test (see below) | 58 + 59 + --- 60 + 61 + ## Win Conditions 62 + 63 + Win conditions define **when the test automatically stops**. They are evaluated externally by UXET — the test app does not need any UXET-specific code. 64 + 65 + ### Syntax 66 + 67 + ``` 68 + data-win="strategy:value" 69 + ``` 70 + 71 + ### Available Strategies 72 + 73 + | Strategy | Syntax | What it detects | 74 + |---|---|---| 75 + | `selector` | `selector:<CSS selector>` | Element exists **and is visible** in the iframe | 76 + | `text` | `text:<substring>` | Substring appears in the iframe's visible text | 77 + | `url` | `url:<glob pattern>` | Iframe URL matches a glob pattern (`*` = wildcard) | 78 + | `postMessage` | `postMessage` | Iframe sends `{ type: 'UXET_TASK_COMPLETE' }` via `postMessage` | 79 + 80 + ### Examples 81 + 82 + ```html 83 + <!-- Fires when .checkout-success becomes visible --> 84 + data-win="selector:.checkout-success.active" 85 + 86 + <!-- Fires when "Order confirmed" appears on the page --> 87 + data-win="text:Order confirmed" 88 + 89 + <!-- Fires when the iframe navigates to a /success URL --> 90 + data-win="url:*/success*" 91 + 92 + <!-- Legacy: app explicitly signals completion --> 93 + data-win="postMessage" 94 + ``` 95 + 96 + ### Strategy Details 97 + 98 + **`selector:`** — Polls every 300ms for the CSS selector inside the iframe DOM. The element must be visible (not `display: none`, `visibility: hidden`, or `opacity: 0`). Best for single-page apps where a success state is reflected by a DOM change. 99 + 100 + **`text:`** — Polls every 500ms for a substring match in `document.body.innerText`. Best for detecting success messages, confirmation text, or any visible string. 101 + 102 + **`url:`** — Polls every 500ms. The glob pattern uses `*` as a wildcard. Best for multi-page flows where success means navigating to a specific URL. 103 + 104 + **`postMessage`** — Listens for `window.postMessage({ type: 'UXET_TASK_COMPLETE' })` from the iframe. The only strategy that works with **cross-origin** iframes. 105 + 106 + > **Cross-origin note:** `selector:` and `text:` require same-origin iframe access. If the iframe is cross-origin, use `url:` or `postMessage` instead. UXET will log a warning to the console if a DOM-based strategy fails due to CORS restrictions. 107 + 108 + --- 109 + 110 + ## Test Flow 111 + 112 + ``` 113 + Load App → Calibration → Task Briefing → Testing → Debrief + Heatmap 114 + ``` 115 + 116 + 1. **Load**: App is loaded into the iframe; WebGazer initializes the webcam (hidden) 117 + 2. **Calibration**: 9-point gaze calibration grid. Click each point 5 times. An advisory card explains the process first. 118 + 3. **Task Briefing**: Shows the task description (`data-task`). Participant clicks "Begin Testing" when ready. 119 + 4. **Testing**: Participant interacts with the app. Mouse, keyboard, scroll, and gaze events are recorded. The win condition watcher runs in the background. 120 + 5. **Debrief**: Shows session stats (time, clicks, keystrokes, scroll events, gaze points, fixations) and a rendered gaze heatmap. 121 + 122 + --- 123 + 124 + ## File Structure 125 + 126 + ``` 127 + UXET/ 128 + ├── index.html # Main UXET shell (dropdown, calibration, briefing, debrief) 129 + ├── index.css # All styles 130 + ├── js/ 131 + │ ├── main.js # App controller — flow, win conditions, heatmap 132 + │ ├── tracker.js # Mouse, keyboard, scroll event tracking 133 + │ ├── session.js # Session timer and state management 134 + │ └── gazeTracker.js # WebGazer.js wrapper — gaze data collection 135 + ├── testable-apps/ 136 + │ ├── shop-app/ 137 + │ │ └── index.html # ShopEasy store demo (win: checkout success) 138 + │ └── example-app/ 139 + │ └── index.html # Form demo (win: form submitted) 140 + └── README.md 141 + ``` 142 + 143 + ### Key Modules 144 + 145 + | Module | Responsibility | 146 + |---|---| 147 + | `main.js` (`UXETApp`) | Orchestrates the entire flow: app loading, calibration, testing, win conditions, heatmap rendering, data export | 148 + | `tracker.js` (`Tracker`) | Attaches to the iframe and records mouse position/clicks/distance, keyboard events, and scroll events | 149 + | `session.js` (`Session`) | Manages session lifecycle (start/stop/reset) and elapsed time | 150 + | `gazeTracker.js` (`GazeTracker`) | Initializes WebGazer.js, runs calibration, collects gaze coordinates mapped to iframe-relative positions, detects fixations | 151 + 152 + --- 153 + 154 + ## Heatmap 155 + 156 + When a test completes, UXET renders a gaze density heatmap on a `<canvas>` in the debrief screen: 157 + 158 + - Each gaze point is drawn as a **Gaussian splat** (radial gradient) 159 + - Intensity is normalized and colorized: **blue → cyan → green → yellow → red** 160 + - Only gaze points that fell within the iframe are included 161 + - A reference grid is drawn for spatial context 162 + 163 + --- 164 + 165 + ## Debug Skip 166 + 167 + For automated testing, an invisible 20×20px button at the top-right corner of the calibration overlay bypasses calibration entirely. It has `id="debug-skip-calibration"` with `opacity: 0`. 168 + 169 + --- 170 + 171 + ## Data Export 172 + 173 + Click **Export Data** on the debrief screen to download a JSON file containing: 174 + 175 + - Session metadata (app, task, duration) 176 + - All tracked events (mouse, keyboard, scroll) 177 + - Raw gaze data points with iframe-relative coordinates 178 + - Per-screen heatmap data 179 + - Aggregate statistics 180 + 181 + --- 182 + 183 + ## Dependencies 184 + 185 + - [WebGazer.js](https://webgazer.cs.brown.edu/) — loaded from jsDelivr CDN 186 + - [MediaPipe Face Mesh](https://google.github.io/mediapipe/) — loaded internally by WebGazer from jsDelivr CDN 187 + 188 + No build step, no npm install. Just serve and open.
+111 -789
index.css
··· 1 - /* UXET - UX Testing Framework Styles */ 1 + /* UXET Minimal Styles */ 2 2 3 - :root { 4 - /* Colors */ 5 - --bg-primary: #0a0a0f; 6 - --bg-secondary: #12121a; 7 - --bg-tertiary: #1a1a24; 8 - --bg-glass: rgba(26, 26, 36, 0.8); 9 - 10 - --text-primary: #f0f0f5; 11 - --text-secondary: #a0a0b0; 12 - --text-muted: #606070; 13 - 14 - --accent-primary: #6366f1; 15 - --accent-primary-hover: #818cf8; 16 - --accent-secondary: #22d3ee; 17 - --accent-success: #22c55e; 18 - --accent-danger: #ef4444; 19 - --accent-warning: #f59e0b; 20 - 21 - --border-color: rgba(255, 255, 255, 0.08); 22 - --border-glow: rgba(99, 102, 241, 0.4); 23 - 24 - /* Typography */ 25 - --font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; 26 - 27 - /* Spacing */ 28 - --spacing-xs: 4px; 29 - --spacing-sm: 8px; 30 - --spacing-md: 16px; 31 - --spacing-lg: 24px; 32 - --spacing-xl: 32px; 33 - 34 - /* Borders */ 35 - --radius-sm: 6px; 36 - --radius-md: 10px; 37 - --radius-lg: 16px; 38 - 39 - /* Transitions */ 40 - --transition-fast: 150ms ease; 41 - --transition-normal: 250ms ease; 42 - 43 - /* Shadows */ 44 - --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3); 45 - --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4); 46 - --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5); 47 - --shadow-glow: 0 0 20px rgba(99, 102, 241, 0.3); 48 - } 49 - 50 - /* Reset & Base */ 51 - *, 52 - *::before, 53 - *::after { 54 - box-sizing: border-box; 3 + body { 4 + font-family: system-ui, -apple-system, sans-serif; 55 5 margin: 0; 56 - padding: 0; 57 - } 58 - 59 - html, 60 - body { 61 - height: 100%; 62 - overflow: hidden; 63 - } 64 - 65 - body { 66 - font-family: var(--font-family); 67 - background: var(--bg-primary); 68 - color: var(--text-primary); 6 + padding: 20px; 7 + background: #fff; 8 + color: #000; 69 9 line-height: 1.5; 70 10 } 71 11 72 - /* Utility Classes */ 73 - .hidden { 74 - display: none !important; 75 - } 76 - 77 - /* App Container */ 78 - .app-container { 12 + /* Base Layout */ 13 + #app-container { 79 14 display: flex; 80 15 flex-direction: column; 81 - height: 100vh; 82 - background: 83 - radial-gradient(ellipse at top left, rgba(99, 102, 241, 0.1) 0%, transparent 50%), 84 - radial-gradient(ellipse at bottom right, rgba(34, 211, 238, 0.08) 0%, transparent 50%), 85 - var(--bg-primary); 86 - } 87 - 88 - /* Testing mode - hide header, full screen app */ 89 - .app-container.testing-mode .header { 90 - display: none; 91 - } 92 - 93 - .app-container.testing-mode .iframe-header { 94 - display: none; 95 - } 96 - 97 - .app-container.testing-mode .main-content { 98 - padding: 0; 99 - } 100 - 101 - .app-container.testing-mode .iframe-wrapper { 102 - border-radius: 0; 103 - border: none; 104 - } 105 - 106 - /* Header */ 107 - .header { 108 - display: flex; 109 - align-items: center; 110 - justify-content: space-between; 111 - padding: var(--spacing-md) var(--spacing-lg); 112 - background: var(--bg-glass); 113 - backdrop-filter: blur(12px); 114 - border-bottom: 1px solid var(--border-color); 115 - gap: var(--spacing-lg); 116 - transition: all var(--transition-normal); 117 - position: relative; 118 - z-index: 100; 119 - } 120 - 121 - .header-left { 122 - display: flex; 123 - align-items: center; 124 - gap: var(--spacing-md); 125 - } 126 - 127 - .logo { 128 - display: flex; 129 - align-items: center; 130 - gap: var(--spacing-sm); 131 - font-size: 1.5rem; 132 - font-weight: 700; 133 - background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); 134 - -webkit-background-clip: text; 135 - -webkit-text-fill-color: transparent; 136 - background-clip: text; 137 - } 138 - 139 - .logo-icon { 140 - font-size: 1.8rem; 141 - animation: pulse 2s ease-in-out infinite; 142 - } 143 - 144 - @keyframes pulse { 145 - 146 - 0%, 147 - 100% { 148 - opacity: 1; 149 - transform: scale(1); 150 - } 151 - 152 - 50% { 153 - opacity: 0.7; 154 - transform: scale(1.05); 155 - } 156 - } 157 - 158 - .tagline { 159 - color: var(--text-muted); 160 - font-size: 0.875rem; 161 - font-weight: 400; 162 - } 163 - 164 - .header-center { 165 - flex: 1; 166 - display: flex; 167 - justify-content: center; 168 - align-items: center; 169 - gap: var(--spacing-sm); 170 - } 171 - 172 - .header-right { 173 - display: flex; 174 - align-items: center; 175 - } 176 - 177 - /* Custom Dropdown */ 178 - .custom-dropdown { 179 - position: relative; 180 - z-index: 1000; 181 - } 182 - 183 - .dropdown-trigger { 184 - display: flex; 185 - align-items: center; 186 - gap: var(--spacing-sm); 187 - background: var(--bg-tertiary); 188 - border: 1px solid var(--border-color); 189 - border-radius: var(--radius-md); 190 - padding: var(--spacing-sm) var(--spacing-md); 191 - cursor: pointer; 192 - transition: all var(--transition-fast); 193 - min-width: 280px; 194 - } 195 - 196 - .dropdown-trigger:hover { 197 - border-color: var(--accent-primary); 198 - } 199 - 200 - .dropdown-label { 201 - color: var(--text-secondary); 202 - font-size: 0.875rem; 203 - font-weight: 500; 204 - } 205 - 206 - .dropdown-value { 207 - flex: 1; 208 - color: var(--text-primary); 209 - font-size: 0.875rem; 210 - text-align: left; 211 - } 212 - 213 - .dropdown-arrow { 214 - color: var(--text-muted); 215 - font-size: 0.65rem; 216 - transition: transform var(--transition-fast); 217 - } 218 - 219 - .custom-dropdown.open .dropdown-arrow { 220 - transform: rotate(180deg); 221 - } 222 - 223 - .dropdown-menu { 224 - position: absolute; 225 - top: calc(100% + 4px); 226 - left: 0; 227 - right: 0; 228 - background: var(--bg-secondary); 229 - border: 1px solid var(--border-color); 230 - border-radius: var(--radius-md); 231 - box-shadow: var(--shadow-lg); 232 - z-index: 1001; 233 - opacity: 0; 234 - visibility: hidden; 235 - transform: translateY(-8px); 236 - transition: all var(--transition-fast); 16 + gap: 15px; 17 + height: calc(100vh - 40px); 237 18 } 238 19 239 - .custom-dropdown.open .dropdown-menu { 240 - opacity: 1; 241 - visibility: visible; 242 - transform: translateY(0); 20 + #controls, 21 + #status-bar { 22 + padding: 10px; 23 + border: 1px solid #ccc; 24 + background: #f9f9f9; 243 25 } 244 26 245 - .dropdown-item { 27 + #controls { 246 28 display: flex; 247 - flex-direction: column; 248 - gap: 2px; 249 - padding: var(--spacing-md); 250 - cursor: pointer; 251 - border-bottom: 1px solid var(--border-color); 252 - transition: background var(--transition-fast); 253 - } 254 - 255 - .dropdown-item:last-child { 256 - border-bottom: none; 257 - } 258 - 259 - .dropdown-item:hover { 260 - background: var(--bg-tertiary); 261 - } 262 - 263 - .dropdown-item.selected { 264 - background: rgba(99, 102, 241, 0.15); 265 - } 266 - 267 - .item-name { 268 - font-weight: 500; 269 - color: var(--text-primary); 270 - } 271 - 272 - .item-task { 273 - font-size: 0.8rem; 274 - color: var(--text-muted); 275 - } 276 - 277 - /* Buttons */ 278 - .btn { 279 - display: inline-flex; 280 29 align-items: center; 281 - gap: var(--spacing-xs); 282 - padding: var(--spacing-sm) var(--spacing-md); 283 - font-family: inherit; 284 - font-size: 0.875rem; 285 - font-weight: 500; 286 - border: none; 287 - border-radius: var(--radius-sm); 288 - cursor: pointer; 289 - transition: all var(--transition-fast); 290 - } 291 - 292 - .btn:disabled { 293 - opacity: 0.5; 294 - cursor: not-allowed; 295 - } 296 - 297 - .btn-icon { 298 - font-size: 0.75rem; 299 - } 300 - 301 - .btn-primary { 302 - background: linear-gradient(135deg, var(--accent-primary), #4f46e5); 303 - color: white; 304 - box-shadow: var(--shadow-sm), 0 0 12px rgba(99, 102, 241, 0.3); 305 - } 306 - 307 - .btn-primary:hover:not(:disabled) { 308 - background: linear-gradient(135deg, var(--accent-primary-hover), var(--accent-primary)); 309 - transform: translateY(-1px); 310 - box-shadow: var(--shadow-md), 0 0 20px rgba(99, 102, 241, 0.4); 311 - } 312 - 313 - .btn-secondary { 314 - background: var(--bg-tertiary); 315 - color: var(--text-primary); 316 - border: 1px solid var(--border-color); 30 + gap: 15px; 317 31 } 318 32 319 - .btn-secondary:hover:not(:disabled) { 320 - background: var(--bg-secondary); 321 - border-color: var(--text-muted); 33 + #controls h1 { 34 + margin: 0; 35 + font-size: 1.4rem; 322 36 } 323 37 324 - .btn-accent { 325 - background: linear-gradient(135deg, var(--accent-secondary), #06b6d4); 326 - color: var(--bg-primary); 327 - font-weight: 600; 38 + #app-select { 39 + padding: 4px; 40 + font-size: 1rem; 328 41 } 329 42 330 - .btn-accent:hover:not(:disabled) { 331 - background: linear-gradient(135deg, #67e8f9, var(--accent-secondary)); 332 - transform: translateY(-1px); 333 - } 334 - 335 - .btn-large { 336 - padding: var(--spacing-md) var(--spacing-xl); 337 - font-size: 1.1rem; 338 - } 339 - 340 - /* Main Content */ 341 - .main-content { 342 - display: flex; 343 - flex: 1; 344 - overflow: hidden; 345 - gap: var(--spacing-lg); 346 - padding: var(--spacing-lg); 347 - transition: padding var(--transition-normal); 348 - } 349 - 350 - /* Iframe Wrapper */ 351 - .iframe-wrapper { 352 - flex: 1; 43 + #main-content { 353 44 display: flex; 354 45 flex-direction: column; 355 - background: var(--bg-glass); 356 - backdrop-filter: blur(12px); 357 - border-radius: var(--radius-lg); 358 - border: 1px solid var(--border-color); 359 - overflow: hidden; 360 - box-shadow: var(--shadow-lg); 361 - transition: all var(--transition-normal); 362 - } 363 - 364 - .iframe-header { 365 - display: flex; 366 - justify-content: space-between; 367 - align-items: center; 368 - padding: var(--spacing-sm) var(--spacing-md); 369 - background: var(--bg-tertiary); 370 - border-bottom: 1px solid var(--border-color); 371 - } 372 - 373 - .iframe-status { 374 - color: var(--text-secondary); 375 - font-size: 0.8rem; 376 - } 377 - 378 - .session-timer { 379 - font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; 380 - font-size: 0.875rem; 381 - color: var(--accent-secondary); 382 - font-weight: 500; 383 - } 384 - 385 - .iframe-container { 386 - flex: 1; 46 + border: 1px solid #ccc; 47 + flex-grow: 1; 387 48 position: relative; 388 - background: var(--bg-primary); 49 + overflow: hidden; 389 50 } 390 51 391 - .iframe-placeholder { 392 - position: absolute; 393 - inset: 0; 394 - display: flex; 395 - align-items: center; 396 - justify-content: center; 397 - background: 398 - radial-gradient(circle at center, rgba(99, 102, 241, 0.05) 0%, transparent 70%), 399 - var(--bg-secondary); 400 - z-index: 1; 52 + /* Helper Classes */ 53 + .hidden { 54 + display: none !important; 401 55 } 402 56 403 - .placeholder-content { 57 + /* Iframe Area */ 58 + #iframe-placeholder { 59 + padding: 40px; 404 60 text-align: center; 405 - padding: var(--spacing-xl); 406 - } 407 - 408 - .placeholder-icon { 409 - font-size: 4rem; 410 - display: block; 411 - margin-bottom: var(--spacing-md); 412 - opacity: 0.6; 413 - } 414 - 415 - .placeholder-content h2 { 416 - color: var(--text-primary); 417 - font-size: 1.5rem; 418 - margin-bottom: var(--spacing-sm); 419 - } 420 - 421 - .placeholder-content p { 422 - color: var(--text-muted); 423 - font-size: 0.95rem; 61 + font-weight: bold; 62 + color: #666; 424 63 } 425 64 426 65 #test-iframe { ··· 430 69 background: white; 431 70 } 432 71 433 - /* Task Briefing Screen */ 434 - .task-briefing { 72 + /* Overlays (Calibration & Debrief) */ 73 + #calibration-screen, 74 + #debrief-screen { 435 75 position: absolute; 436 76 inset: 0; 437 - display: flex; 438 - align-items: center; 439 - justify-content: center; 440 - background: 441 - radial-gradient(circle at center, rgba(99, 102, 241, 0.1) 0%, transparent 60%), 442 - var(--bg-secondary); 443 - z-index: 2; 444 - padding: var(--spacing-xl); 445 - } 446 - 447 - .briefing-card { 448 - background: var(--bg-glass); 449 - backdrop-filter: blur(20px); 450 - border-radius: var(--radius-lg); 451 - border: 1px solid var(--border-color); 452 - box-shadow: var(--shadow-lg), var(--shadow-glow); 453 - max-width: 500px; 454 - width: 100%; 455 - overflow: hidden; 456 - animation: slideUp 0.4s ease-out; 77 + background: #fff; 78 + padding: 20px; 79 + overflow-y: auto; 80 + z-index: 10; 457 81 } 458 82 459 - @keyframes slideUp { 460 - from { 461 - opacity: 0; 462 - transform: translateY(20px); 463 - } 464 - 465 - to { 466 - opacity: 1; 467 - transform: translateY(0); 468 - } 469 - } 470 - 471 - .briefing-header { 472 - background: linear-gradient(135deg, var(--accent-primary), #4f46e5); 473 - padding: var(--spacing-lg); 474 - text-align: center; 475 - } 476 - 477 - .briefing-icon { 478 - font-size: 2.5rem; 479 - display: block; 480 - margin-bottom: var(--spacing-sm); 481 - } 482 - 483 - .briefing-header h2 { 484 - color: white; 485 - font-size: 1.5rem; 486 - font-weight: 600; 487 - } 488 - 489 - .briefing-body { 490 - padding: var(--spacing-lg); 491 - } 492 - 493 - .task-description { 494 - font-size: 1.25rem; 495 - color: var(--text-primary); 496 - text-align: center; 497 - padding: var(--spacing-md); 498 - background: var(--bg-tertiary); 499 - border-radius: var(--radius-md); 500 - border-left: 4px solid var(--accent-primary); 501 - margin-bottom: var(--spacing-lg); 502 - font-weight: 500; 503 - } 504 - 505 - .briefing-info { 83 + #calibration-screen { 506 84 display: flex; 507 85 flex-direction: column; 508 - gap: var(--spacing-sm); 509 86 } 510 87 511 - .info-item { 512 - display: flex; 513 - align-items: center; 514 - gap: var(--spacing-sm); 515 - color: var(--text-secondary); 516 - font-size: 0.875rem; 517 - } 518 - 519 - .info-icon { 520 - font-size: 1rem; 521 - opacity: 0.7; 88 + #calibration-header { 89 + text-align: center; 90 + margin-bottom: 20px; 91 + z-index: 20; 92 + position: relative; 522 93 } 523 94 524 - .briefing-footer { 525 - padding: var(--spacing-lg); 526 - background: var(--bg-tertiary); 527 - text-align: center; 528 - border-top: 1px solid var(--border-color); 95 + #calibration-header h2 { 96 + margin-top: 0; 529 97 } 530 98 531 - .ready-text { 532 - color: var(--text-muted); 533 - font-size: 0.875rem; 534 - margin-bottom: var(--spacing-md); 99 + /* Calibration Grid */ 100 + #calibration-grid { 101 + position: relative; 102 + flex-grow: 1; 103 + min-height: 500px; 535 104 } 536 105 537 - /* Debrief Screen */ 538 - .debrief-screen { 106 + .calibration-point { 539 107 position: absolute; 540 - inset: 0; 541 - display: flex; 542 - align-items: center; 543 - justify-content: center; 544 - background: rgba(10, 10, 15, 0.95); 545 - z-index: 10; 546 - padding: var(--spacing-xl); 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%); 547 115 } 548 116 549 - .debrief-card { 550 - background: var(--bg-glass); 551 - backdrop-filter: blur(20px); 552 - border-radius: var(--radius-lg); 553 - border: 1px solid var(--border-color); 554 - box-shadow: var(--shadow-lg), 0 0 40px rgba(34, 197, 94, 0.2); 555 - max-width: 600px; 556 - width: 100%; 557 - overflow: hidden; 558 - animation: slideUp 0.4s ease-out; 559 - } 560 - 561 - .debrief-header { 562 - background: linear-gradient(135deg, var(--accent-success), #16a34a); 563 - padding: var(--spacing-lg); 564 - text-align: center; 565 - } 566 - 567 - .debrief-icon { 568 - font-size: 3rem; 569 - display: block; 570 - margin-bottom: var(--spacing-sm); 571 - } 572 - 573 - .debrief-header h2 { 117 + .calibration-point.done { 118 + background: #4CAF50; 574 119 color: white; 575 - font-size: 1.75rem; 576 - font-weight: 700; 577 - margin-bottom: var(--spacing-xs); 578 - } 579 - 580 - .debrief-subtitle { 581 - color: rgba(255, 255, 255, 0.8); 582 - font-size: 1rem; 583 - } 584 - 585 - .debrief-body { 586 - padding: var(--spacing-lg); 587 - } 588 - 589 - .debrief-stat-group { 590 - margin-bottom: var(--spacing-lg); 591 - } 592 - 593 - .debrief-stat-group:last-child { 594 - margin-bottom: 0; 595 - } 596 - 597 - .debrief-stat-group h3 { 598 - font-size: 0.9rem; 599 - color: var(--text-secondary); 600 - margin-bottom: var(--spacing-md); 601 - padding-bottom: var(--spacing-sm); 602 - border-bottom: 1px solid var(--border-color); 603 - } 604 - 605 - .debrief-stats { 606 - display: grid; 607 - grid-template-columns: repeat(3, 1fr); 608 - gap: var(--spacing-md); 609 120 } 610 121 611 - .debrief-stat { 612 - background: var(--bg-tertiary); 613 - padding: var(--spacing-md); 614 - border-radius: var(--radius-md); 615 - text-align: center; 616 - } 617 - 618 - .debrief-stat-value { 619 - display: block; 620 - font-size: 1.5rem; 621 - font-weight: 700; 622 - color: var(--accent-secondary); 623 - font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; 624 - margin-bottom: var(--spacing-xs); 625 - } 626 - 627 - .debrief-stat-label { 628 - font-size: 0.75rem; 629 - color: var(--text-muted); 630 - text-transform: uppercase; 631 - letter-spacing: 0.5px; 632 - } 633 - 634 - .debrief-footer { 635 - padding: var(--spacing-lg); 636 - background: var(--bg-tertiary); 637 - display: flex; 638 - gap: var(--spacing-md); 639 - justify-content: center; 640 - border-top: 1px solid var(--border-color); 641 - } 642 - 643 - /* Sidebar */ 644 - .sidebar { 645 - width: 320px; 646 - display: flex; 647 - flex-direction: column; 648 - gap: var(--spacing-md); 649 - overflow-y: auto; 650 - transition: all var(--transition-normal); 651 - } 652 - 653 - .sidebar-section { 654 - background: var(--bg-glass); 655 - backdrop-filter: blur(12px); 656 - border-radius: var(--radius-md); 657 - border: 1px solid var(--border-color); 658 - padding: var(--spacing-md); 659 - transition: border-color var(--transition-normal); 660 - } 661 - 662 - .sidebar-section:hover { 663 - border-color: rgba(255, 255, 255, 0.12); 664 - } 665 - 666 - .section-title { 667 - display: flex; 668 - align-items: center; 669 - gap: var(--spacing-sm); 670 - font-size: 0.9rem; 671 - font-weight: 600; 672 - color: var(--text-primary); 673 - margin-bottom: var(--spacing-md); 674 - padding-bottom: var(--spacing-sm); 675 - border-bottom: 1px solid var(--border-color); 122 + /* Absolute Positioning for 9-point grid */ 123 + #cal-pt-1 { 124 + top: 10%; 125 + left: 10%; 676 126 } 677 127 678 - .section-icon { 679 - font-size: 1rem; 128 + #cal-pt-2 { 129 + top: 10%; 130 + left: 50%; 680 131 } 681 132 682 - .stats-grid { 683 - display: grid; 684 - grid-template-columns: repeat(2, 1fr); 685 - gap: var(--spacing-sm); 133 + #cal-pt-3 { 134 + top: 10%; 135 + left: 90%; 686 136 } 687 137 688 - .stat-item { 689 - background: var(--bg-tertiary); 690 - padding: var(--spacing-sm) var(--spacing-md); 691 - border-radius: var(--radius-sm); 692 - display: flex; 693 - flex-direction: column; 694 - gap: 2px; 138 + #cal-pt-4 { 139 + top: 50%; 140 + left: 10%; 695 141 } 696 142 697 - .stat-item.full-width { 698 - grid-column: span 2; 699 - flex-direction: row; 700 - justify-content: space-between; 701 - align-items: center; 143 + #cal-pt-5 { 144 + top: 50%; 145 + left: 50%; 702 146 } 703 147 704 - .stat-label { 705 - font-size: 0.7rem; 706 - color: var(--text-muted); 707 - text-transform: uppercase; 708 - letter-spacing: 0.5px; 148 + #cal-pt-6 { 149 + top: 50%; 150 + left: 90%; 709 151 } 710 152 711 - .stat-value { 712 - font-size: 1rem; 713 - font-weight: 600; 714 - color: var(--accent-secondary); 715 - font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; 153 + #cal-pt-7 { 154 + top: 90%; 155 + left: 10%; 716 156 } 717 157 718 - .session-status { 719 - padding: 2px 8px; 720 - border-radius: var(--radius-sm); 721 - font-size: 0.8rem; 722 - } 723 - 724 - .session-status.idle { 725 - background: var(--bg-secondary); 726 - color: var(--text-muted); 727 - } 728 - 729 - .session-status.recording { 730 - background: rgba(239, 68, 68, 0.2); 731 - color: var(--accent-danger); 732 - animation: blink 1s ease-in-out infinite; 733 - } 734 - 735 - .session-status.completed { 736 - background: rgba(34, 197, 94, 0.2); 737 - color: var(--accent-success); 738 - } 739 - 740 - @keyframes blink { 741 - 742 - 0%, 743 - 100% { 744 - opacity: 1; 745 - } 746 - 747 - 50% { 748 - opacity: 0.6; 749 - } 750 - } 751 - 752 - /* Task Card */ 753 - .task-card { 754 - border-color: var(--accent-primary); 755 - background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(99, 102, 241, 0.05)); 158 + #cal-pt-8 { 159 + top: 90%; 160 + left: 50%; 756 161 } 757 162 758 - .current-task-text { 759 - color: var(--text-primary); 760 - font-size: 0.9rem; 761 - line-height: 1.5; 163 + #cal-pt-9 { 164 + top: 90%; 165 + left: 90%; 762 166 } 763 167 764 - /* Event Log */ 765 - .event-log { 766 - max-height: 200px; 767 - overflow-y: auto; 768 - background: var(--bg-tertiary); 769 - border-radius: var(--radius-sm); 770 - padding: var(--spacing-sm); 771 - } 772 - 773 - .log-empty { 774 - color: var(--text-muted); 775 - font-size: 0.8rem; 776 - text-align: center; 777 - padding: var(--spacing-md); 778 - } 779 - 780 - .log-entry { 168 + /* Stats & Debrief */ 169 + #stats-container { 781 170 display: flex; 782 - align-items: flex-start; 783 - gap: var(--spacing-sm); 784 - padding: var(--spacing-xs) 0; 785 - font-size: 0.75rem; 786 - border-bottom: 1px solid var(--border-color); 787 - } 788 - 789 - .log-entry:last-child { 790 - border-bottom: none; 791 - } 792 - 793 - .log-time { 794 - color: var(--text-muted); 795 - font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; 796 - white-space: nowrap; 797 - } 798 - 799 - .log-type { 800 - padding: 1px 6px; 801 - border-radius: 3px; 802 - font-size: 0.65rem; 803 - font-weight: 600; 804 - text-transform: uppercase; 805 - } 806 - 807 - .log-type.mouse { 808 - background: rgba(99, 102, 241, 0.2); 809 - color: var(--accent-primary); 810 - } 811 - 812 - .log-type.key { 813 - background: rgba(34, 211, 238, 0.2); 814 - color: var(--accent-secondary); 815 - } 816 - 817 - .log-type.click { 818 - background: rgba(34, 197, 94, 0.2); 819 - color: var(--accent-success); 820 - } 821 - 822 - .log-type.system { 823 - background: rgba(245, 158, 11, 0.2); 824 - color: var(--accent-warning); 825 - } 826 - 827 - .log-message { 828 - color: var(--text-secondary); 829 - flex: 1; 830 - } 831 - 832 - /* Scrollbar */ 833 - ::-webkit-scrollbar { 834 - width: 6px; 835 - height: 6px; 836 - } 837 - 838 - ::-webkit-scrollbar-track { 839 - background: var(--bg-tertiary); 840 - border-radius: 3px; 171 + gap: 40px; 172 + margin-bottom: 20px; 841 173 } 842 174 843 - ::-webkit-scrollbar-thumb { 844 - background: var(--text-muted); 845 - border-radius: 3px; 175 + #stats-container ul { 176 + list-style: none; 177 + padding: 0; 178 + margin: 0; 846 179 } 847 180 848 - ::-webkit-scrollbar-thumb:hover { 849 - background: var(--text-secondary); 181 + #stats-container li { 182 + margin-bottom: 5px; 850 183 } 851 184 852 - /* Responsive */ 853 - @media (max-width: 768px) { 854 - .header { 855 - flex-wrap: wrap; 856 - gap: var(--spacing-md); 857 - } 858 - 859 - .header-center { 860 - order: 3; 861 - flex-basis: 100%; 862 - } 863 - 864 - .dropdown-trigger { 865 - width: 100%; 866 - } 867 - 868 - .debrief-stats { 869 - grid-template-columns: 1fr; 870 - } 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; 871 193 }
+65 -234
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 - UX Testing Framework</title> 7 + <title>UXET - Testing</title> 8 8 <link rel="stylesheet" href="index.css"> 9 - <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> 9 + <script src="https://cdn.jsdelivr.net/npm/webgazer@3.4.0/dist/webgazer.js" crossorigin="anonymous"></script> 10 10 </head> 11 11 12 12 <body> 13 - <div class="app-container"> 14 - <!-- Header - Hidden during testing --> 15 - <header class="header" id="header"> 16 - <div class="header-left"> 17 - <h1 class="logo"> 18 - <span class="logo-icon">◎</span> 19 - UXET 20 - </h1> 21 - <span class="tagline">UX Testing Framework</span> 22 - </div> 23 - <div class="header-center"> 24 - <!-- Custom Dropdown --> 25 - <div class="custom-dropdown" id="app-dropdown"> 26 - <button class="dropdown-trigger" id="dropdown-trigger"> 27 - <span class="dropdown-label">Test App:</span> 28 - <span class="dropdown-value" id="dropdown-value">Select an app...</span> 29 - <span class="dropdown-arrow">▼</span> 30 - </button> 31 - <div class="dropdown-menu" id="dropdown-menu"> 32 - <div class="dropdown-item" data-value="testable-apps/shop-app/index.html" 33 - data-task="Find and purchase a blue t-shirt"> 34 - <span class="item-name">ShopEasy Store</span> 35 - <span class="item-task">Buy a t-shirt</span> 36 - </div> 37 - <div class="dropdown-item" data-value="testable-apps/example-app/index.html" 38 - data-task="Fill out the contact form with your details"> 39 - <span class="item-name">Example Form App</span> 40 - <span class="item-task">Complete form</span> 41 - </div> 42 - </div> 43 - </div> 44 - <button id="load-app-btn" class="btn btn-secondary">Load</button> 45 - </div> 46 - <div class="header-right"> 47 - <button id="reset-btn" class="btn btn-secondary"> 48 - <span class="btn-icon">↻</span> Reset 49 - </button> 50 - </div> 13 + <div id="app-container"> 14 + <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> 51 30 </header> 52 31 53 - <!-- Main Content --> 54 - <main class="main-content" id="main-content"> 55 - <!-- Iframe Container --> 56 - <div class="iframe-wrapper" id="iframe-wrapper"> 57 - <div class="iframe-header" id="iframe-header"> 58 - <span class="iframe-status" id="iframe-status">No app loaded</span> 59 - <span class="session-timer" id="session-timer">00:00:00</span> 60 - </div> 61 - <div class="iframe-container" id="iframe-container"> 62 - <!-- Initial placeholder --> 63 - <div class="iframe-placeholder" id="iframe-placeholder"> 64 - <div class="placeholder-content"> 65 - <span class="placeholder-icon">🖥️</span> 66 - <h2>Select an App to Test</h2> 67 - <p>Choose an app from the dropdown above and click "Load" to begin</p> 68 - </div> 69 - </div> 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> 70 37 71 - <!-- Task Briefing Screen --> 72 - <div class="task-briefing hidden" id="task-briefing"> 73 - <div class="briefing-card"> 74 - <div class="briefing-header"> 75 - <span class="briefing-icon">📋</span> 76 - <h2>Your Task</h2> 77 - </div> 78 - <div class="briefing-body"> 79 - <p class="task-description" id="task-description"></p> 80 - <div class="briefing-info"> 81 - <div class="info-item"> 82 - <span class="info-icon">⏱️</span> 83 - <span>Complete the task at your own pace</span> 84 - </div> 85 - <div class="info-item"> 86 - <span class="info-icon">✓</span> 87 - <span>The test will end automatically when done</span> 88 - </div> 89 - </div> 90 - </div> 91 - <div class="briefing-footer"> 92 - <p class="ready-text">When you're ready, click below to start</p> 93 - <button id="begin-test-btn" class="btn btn-primary btn-large"> 94 - <span class="btn-icon">▶</span> Begin Testing 95 - </button> 96 - </div> 97 - </div> 98 - </div> 38 + <div id="main-content"> 39 + <div id="iframe-placeholder">Select an app and click "Load App" to begin.</div> 99 40 100 - <!-- Debrief Screen --> 101 - <div class="debrief-screen hidden" id="debrief-screen"> 102 - <div class="debrief-card"> 103 - <div class="debrief-header"> 104 - <span class="debrief-icon">✅</span> 105 - <h2>Task Complete!</h2> 106 - <p class="debrief-subtitle">Here's how you did</p> 107 - </div> 108 - <div class="debrief-body"> 109 - <div class="debrief-stat-group"> 110 - <h3>📊 Session Summary</h3> 111 - <div class="debrief-stats"> 112 - <div class="debrief-stat"> 113 - <span class="debrief-stat-value" id="debrief-time">00:00</span> 114 - <span class="debrief-stat-label">Time to Complete</span> 115 - </div> 116 - <div class="debrief-stat"> 117 - <span class="debrief-stat-value" id="debrief-clicks">0</span> 118 - <span class="debrief-stat-label">Total Clicks</span> 119 - </div> 120 - <div class="debrief-stat"> 121 - <span class="debrief-stat-value" id="debrief-keys">0</span> 122 - <span class="debrief-stat-label">Keystrokes</span> 123 - </div> 124 - </div> 125 - </div> 126 - <div class="debrief-stat-group"> 127 - <h3>🖱️ Mouse Activity</h3> 128 - <div class="debrief-stats"> 129 - <div class="debrief-stat"> 130 - <span class="debrief-stat-value" id="debrief-distance">0</span> 131 - <span class="debrief-stat-label">Distance (px)</span> 132 - </div> 133 - <div class="debrief-stat"> 134 - <span class="debrief-stat-value" id="debrief-scrolls">0</span> 135 - <span class="debrief-stat-label">Scroll Events</span> 136 - </div> 137 - <div class="debrief-stat"> 138 - <span class="debrief-stat-value" id="debrief-velocity">0</span> 139 - <span class="debrief-stat-label">Avg Speed (px/s)</span> 140 - </div> 141 - </div> 142 - </div> 143 - </div> 144 - <div class="debrief-footer"> 145 - <button id="export-btn" class="btn btn-accent"> 146 - <span class="btn-icon">↓</span> Export Data 147 - </button> 148 - <button id="new-test-btn" class="btn btn-secondary"> 149 - Start New Test 150 - </button> 151 - </div> 152 - </div> 153 - </div> 154 - 155 - <iframe id="test-iframe" src="" title="Test Application"></iframe> 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> 46 + </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> 156 57 </div> 157 58 </div> 158 59 159 - <!-- Tracking Sidebar - Hidden during testing, shown in setup/debrief --> 160 - <aside class="sidebar hidden" id="sidebar"> 161 - <div class="sidebar-section task-card" id="task-card"> 162 - <h3 class="section-title"> 163 - <span class="section-icon">🎯</span> 164 - Current Task 165 - </h3> 166 - <p class="current-task-text" id="current-task-text">No task loaded</p> 167 - </div> 60 + <iframe id="test-iframe" class="hidden" title="Test Application"></iframe> 168 61 169 - <div class="sidebar-section"> 170 - <h3 class="section-title"> 171 - <span class="section-icon">🖱️</span> 172 - Mouse Tracking 173 - </h3> 174 - <div class="stats-grid"> 175 - <div class="stat-item"> 176 - <span class="stat-label">Position</span> 177 - <span class="stat-value" id="mouse-position">--, --</span> 178 - </div> 179 - <div class="stat-item"> 180 - <span class="stat-label">Movements</span> 181 - <span class="stat-value" id="mouse-movements">0</span> 182 - </div> 183 - <div class="stat-item"> 184 - <span class="stat-label">Clicks</span> 185 - <span class="stat-value" id="mouse-clicks">0</span> 186 - </div> 187 - <div class="stat-item"> 188 - <span class="stat-label">Distance (px)</span> 189 - <span class="stat-value" id="mouse-distance">0</span> 190 - </div> 191 - <div class="stat-item"> 192 - <span class="stat-label">Avg Velocity</span> 193 - <span class="stat-value" id="mouse-velocity">0 px/s</span> 194 - </div> 195 - <div class="stat-item"> 196 - <span class="stat-label">Scroll Events</span> 197 - <span class="stat-value" id="scroll-events">0</span> 198 - </div> 199 - </div> 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> 200 78 </div> 201 79 202 - <div class="sidebar-section"> 203 - <h3 class="section-title"> 204 - <span class="section-icon">⌨️</span> 205 - Keystroke Tracking 206 - </h3> 207 - <div class="stats-grid"> 208 - <div class="stat-item"> 209 - <span class="stat-label">Total Keys</span> 210 - <span class="stat-value" id="total-keys">0</span> 211 - </div> 212 - <div class="stat-item"> 213 - <span class="stat-label">Keys/Min</span> 214 - <span class="stat-value" id="keys-per-minute">0</span> 215 - </div> 216 - <div class="stat-item"> 217 - <span class="stat-label">Last Key</span> 218 - <span class="stat-value" id="last-key">--</span> 219 - </div> 220 - <div class="stat-item"> 221 - <span class="stat-label">Backspaces</span> 222 - <span class="stat-value" id="backspace-count">0</span> 223 - </div> 224 - </div> 225 - </div> 80 + <h3>Heatmap</h3> 81 + <canvas id="heatmap-canvas"></canvas> 82 + <p id="heatmap-label">Gaze density overlay</p> 226 83 227 - <div class="sidebar-section"> 228 - <h3 class="section-title"> 229 - <span class="section-icon">📊</span> 230 - Session Info 231 - </h3> 232 - <div class="stats-grid"> 233 - <div class="stat-item full-width"> 234 - <span class="stat-label">Status</span> 235 - <span class="stat-value session-status" id="session-status">Idle</span> 236 - </div> 237 - <div class="stat-item full-width"> 238 - <span class="stat-label">Total Events</span> 239 - <span class="stat-value" id="total-events">0</span> 240 - </div> 241 - </div> 242 - </div> 243 - 244 - <div class="sidebar-section"> 245 - <h3 class="section-title"> 246 - <span class="section-icon">📝</span> 247 - Event Log 248 - </h3> 249 - <div class="event-log" id="event-log"> 250 - <div class="log-empty">No events recorded</div> 251 - </div> 252 - </div> 253 - </aside> 254 - </main> 84 + <button id="export-btn">Export Data</button> 85 + </div> 86 + </div> 255 87 </div> 256 - 257 88 <script type="module" src="js/main.js"></script> 258 89 </body> 259 90
+364
js/gazeTracker.js
··· 1 + /** 2 + * UXET GazeTracker - WebGazer.js wrapper for gaze tracking 3 + * Collects viewport-relative gaze coordinates for heatmap generation 4 + */ 5 + 6 + export class GazeTracker { 7 + constructor() { 8 + this.isActive = false; 9 + this.gazeData = []; 10 + this.stats = this.createInitialStats(); 11 + 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 22 + this.currentFixation = null; 23 + 24 + // Throttle gaze logging 25 + this.lastLogTime = 0; 26 + this.logInterval = 50; // ms — collect at ~20Hz for smooth heatmaps 27 + } 28 + 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 + async initialize() { 46 + if (typeof webgazer === 'undefined') { 47 + console.error('WebGazer.js is not loaded'); 48 + return false; 49 + } 50 + 51 + try { 52 + // Don't persist calibration data between sessions 53 + 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 + 59 + webgazer 60 + .setRegression('ridge') 61 + .setGazeListener((data, elapsedTime) => { 62 + if (!this.isActive || !data) return; 63 + this.handleGaze(data, elapsedTime); 64 + }); 65 + 66 + await webgazer.begin(); 67 + 68 + // Always hide video feed and prediction dot — never show to user 69 + webgazer.showVideoPreview(false); 70 + webgazer.showPredictionPoints(false); 71 + 72 + return true; 73 + } catch (e) { 74 + console.error('Failed to initialize WebGazer:', e); 75 + return false; 76 + } 77 + } 78 + 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') { 94 + webgazer.resume(); 95 + } 96 + } 97 + 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; 106 + } 107 + } 108 + 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] = []; 118 + } 119 + } 120 + 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(); 128 + } 129 + } 130 + 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(); 140 + } 141 + 142 + if (typeof webgazer !== 'undefined') { 143 + webgazer.pause(); 144 + } 145 + } 146 + 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 + } 161 + 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 + ]; 170 + 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); 178 + } 179 + } 180 + 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); 200 + 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); 208 + 209 + // Throttled logging 210 + if (now - this.lastLogTime >= this.logInterval) { 211 + this.lastLogTime = now; 212 + 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 + } 217 + 218 + // Map to iframe-relative coordinates for heatmap 219 + let iframeX = viewportX; 220 + let iframeY = viewportY; 221 + let inIframe = false; 222 + 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 + } 231 + 232 + // Ensure we have a screen key, fallback if needed 233 + const screenKey = this.currentScreenKey || 'default'; 234 + 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 + }; 249 + 250 + this.gazeData.push(gazePoint); 251 + 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 + } 265 + 266 + if (this.onGazeData) { 267 + this.onGazeData(gazePoint); 268 + } 269 + } 270 + 271 + if (this.onStatsUpdate) { 272 + this.onStatsUpdate({ ...this.stats }); 273 + } 274 + } 275 + 276 + /** 277 + * Simple fixation detection based on spatial proximity over time 278 + */ 279 + detectFixation(x, y, now) { 280 + if (!this.currentFixation) { 281 + this.currentFixation = { 282 + x, y, startTime: now, pointCount: 1 283 + }; 284 + return; 285 + } 286 + 287 + const dx = x - this.currentFixation.x; 288 + const dy = y - this.currentFixation.y; 289 + const distance = Math.sqrt(dx * dx + dy * dy); 290 + 291 + if (distance <= this.fixationThreshold) { 292 + this.currentFixation.pointCount++; 293 + this.currentFixation.x = 294 + (this.currentFixation.x * (this.currentFixation.pointCount - 1) + x) / 295 + this.currentFixation.pointCount; 296 + this.currentFixation.y = 297 + (this.currentFixation.y * (this.currentFixation.pointCount - 1) + y) / 298 + this.currentFixation.pointCount; 299 + } else { 300 + this.finalizeFixation(); 301 + this.currentFixation = { 302 + x, y, startTime: now, pointCount: 1 303 + }; 304 + } 305 + } 306 + 307 + /** 308 + * Check if the current fixation is long enough and record it 309 + */ 310 + finalizeFixation() { 311 + if (!this.currentFixation) return; 312 + 313 + const duration = Date.now() - this.currentFixation.startTime; 314 + if (duration >= this.fixationMinDuration) { 315 + this.stats.fixations++; 316 + this.stats.totalFixationDuration += duration; 317 + this.stats.avgFixationDuration = Math.round( 318 + this.stats.totalFixationDuration / this.stats.fixations 319 + ); 320 + } 321 + this.currentFixation = null; 322 + } 323 + 324 + /** 325 + * Get current stats 326 + */ 327 + getStats() { 328 + return { ...this.stats }; 329 + } 330 + 331 + /** 332 + * Get all logged gaze data points 333 + */ 334 + getGazeData() { 335 + return [...this.gazeData]; 336 + } 337 + 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; 348 + } 349 + 350 + /** 351 + * Export gaze data for session export 352 + */ 353 + exportData() { 354 + return { 355 + 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 362 + }; 363 + } 364 + }
+484 -196
js/main.js
··· 1 - /** 2 - * UXET Main - Application entry point 3 - */ 4 - 5 1 import { Tracker } from './tracker.js'; 6 2 import { Session } from './session.js'; 3 + import { GazeTracker } from './gazeTracker.js'; 7 4 8 5 class UXETApp { 9 6 constructor() { 10 7 this.tracker = new Tracker(); 11 8 this.session = new Session(); 9 + this.gazeTracker = new GazeTracker(); 12 10 this.currentTask = ''; 13 11 this.selectedApp = null; 12 + this.gazeInitialized = false; 13 + 14 + this._winWatcher = null; 15 + 16 + this.calibrationClicks = {}; 17 + this.calibratedPoints = 0; 18 + this.CLICKS_PER_POINT = 5; 19 + this.TOTAL_POINTS = 9; 14 20 15 21 this.elements = { 16 - appContainer: document.querySelector('.app-container'), 17 - header: document.getElementById('header'), 18 - // Custom dropdown 19 - dropdown: document.getElementById('app-dropdown'), 20 - dropdownTrigger: document.getElementById('dropdown-trigger'), 21 - dropdownValue: document.getElementById('dropdown-value'), 22 - dropdownMenu: document.getElementById('dropdown-menu'), 23 - // Buttons 22 + appContainer: document.getElementById('app-container'), 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 - newTestBtn: document.getElementById('new-test-btn'), 28 - beginTestBtn: document.getElementById('begin-test-btn'), 29 - // Iframe 27 + 28 + sessionStatus: document.getElementById('session-status'), 29 + sessionTimer: document.getElementById('session-timer'), 30 + currentTaskText: document.getElementById('current-task-text'), 31 + 32 + iframePlaceholder: document.getElementById('iframe-placeholder'), 30 33 iframe: document.getElementById('test-iframe'), 31 - iframePlaceholder: document.getElementById('iframe-placeholder'), 32 - iframeHeader: document.getElementById('iframe-header'), 33 - iframeStatus: document.getElementById('iframe-status'), 34 - // Screens 35 - taskBriefing: document.getElementById('task-briefing'), 34 + 35 + 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'), 41 + 36 42 debriefScreen: document.getElementById('debrief-screen'), 37 - taskDescription: document.getElementById('task-description'), 38 - // Timer 39 - sessionTimer: document.getElementById('session-timer'), 40 - // Sidebar (for internal tracking, hidden during test) 41 - sidebar: document.getElementById('sidebar'), 42 - currentTaskText: document.getElementById('current-task-text'), 43 - sessionStatus: document.getElementById('session-status'), 44 - // Debrief stats 45 43 debriefTime: document.getElementById('debrief-time'), 46 44 debriefClicks: document.getElementById('debrief-clicks'), 47 45 debriefKeys: document.getElementById('debrief-keys'), 48 46 debriefDistance: document.getElementById('debrief-distance'), 49 47 debriefScrolls: document.getElementById('debrief-scrolls'), 50 48 debriefVelocity: document.getElementById('debrief-velocity'), 51 - // Hidden stats (still tracked internally) 52 - mousePosition: document.getElementById('mouse-position'), 53 - mouseMovements: document.getElementById('mouse-movements'), 54 - mouseClicks: document.getElementById('mouse-clicks'), 55 - mouseDistance: document.getElementById('mouse-distance'), 56 - mouseVelocity: document.getElementById('mouse-velocity'), 57 - scrollEvents: document.getElementById('scroll-events'), 58 - totalKeys: document.getElementById('total-keys'), 59 - keysPerMinute: document.getElementById('keys-per-minute'), 60 - lastKey: document.getElementById('last-key'), 61 - backspaceCount: document.getElementById('backspace-count'), 62 - totalEvents: document.getElementById('total-events'), 63 - eventLog: document.getElementById('event-log') 49 + debriefGazePoints: document.getElementById('debrief-gaze-points'), 50 + debriefFixations: document.getElementById('debrief-fixations'), 51 + debriefFixationDuration: document.getElementById('debrief-fixation-duration'), 52 + 53 + heatmapCanvas: document.getElementById('heatmap-canvas'), 54 + heatmapLabel: document.getElementById('heatmap-label'), 64 55 }; 65 56 66 57 this.init(); ··· 69 60 init() { 70 61 this.bindEvents(); 71 62 this.setupCallbacks(); 72 - this.setupCompletionAPI(); 73 63 } 74 64 75 65 bindEvents() { 76 - // Custom dropdown 77 - this.elements.dropdownTrigger.addEventListener('click', (e) => { 78 - e.stopPropagation(); 79 - this.toggleDropdown(); 80 - }); 81 - 82 - document.querySelectorAll('.dropdown-item').forEach(item => { 83 - item.addEventListener('click', () => this.selectApp(item)); 84 - }); 85 - 86 - // Close dropdown when clicking outside 87 - document.addEventListener('click', () => this.closeDropdown()); 88 - 89 - // Buttons 66 + this.elements.appSelect.addEventListener('change', (e) => this.selectApp(e.target)); 90 67 this.elements.loadAppBtn.addEventListener('click', () => this.loadApp()); 91 - this.elements.beginTestBtn.addEventListener('click', () => this.beginTesting()); 68 + this.elements.startTestBtn.addEventListener('click', () => this.beginTesting()); 92 69 this.elements.resetBtn.addEventListener('click', () => this.resetSession()); 93 70 this.elements.exportBtn.addEventListener('click', () => this.exportData()); 94 - this.elements.newTestBtn.addEventListener('click', () => this.resetSession()); 71 + this.elements.debugSkipBtn.addEventListener('click', () => this.skipCalibration()); 72 + this.elements.iframe.addEventListener('load', () => this.onIframeLoad()); 95 73 96 - this.elements.iframe.addEventListener('load', () => this.onIframeLoad()); 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 + 81 + window.addEventListener('resize', () => { 82 + if (this.gazeTracker && this.elements.iframe) { 83 + this.gazeTracker.updateIframeRect(this.elements.iframe); 84 + } 85 + }); 97 86 } 98 87 99 88 setupCallbacks() { 100 - this.tracker.onStatsUpdate = (stats) => this.updateStats(stats); 101 - this.tracker.onEvent = (event) => this.addEventToLog(event); 102 - 103 89 this.session.onStatusChange = (status) => this.updateSessionStatus(status); 104 90 this.session.onTimerUpdate = (time) => { 105 91 this.elements.sessionTimer.textContent = time; 106 92 }; 107 - } 108 93 109 - setupCompletionAPI() { 110 - // Listen for postMessage from iframe 111 - window.addEventListener('message', (e) => { 112 - if (e.data?.type === 'UXET_TASK_COMPLETE') { 113 - this.onTaskComplete(e.data.details || {}); 94 + this.gazeTracker.onGazeData = (gazePoint) => { 95 + if (this.tracker.isRecording) { 96 + this.tracker.logEvent('gaze', `Gaze at (${gazePoint.x}, ${gazePoint.y})`); 114 97 } 115 - }); 98 + }; 116 99 } 117 100 118 - // Custom dropdown methods 119 - toggleDropdown() { 120 - this.elements.dropdown.classList.toggle('open'); 121 - } 101 + selectApp(selectElement) { 102 + const option = selectElement.options[selectElement.selectedIndex]; 103 + if (!option.value) { 104 + this.selectedApp = null; 105 + return; 106 + } 122 107 123 - closeDropdown() { 124 - this.elements.dropdown.classList.remove('open'); 125 - } 126 - 127 - selectApp(item) { 128 - // Update selection UI 129 - document.querySelectorAll('.dropdown-item').forEach(i => i.classList.remove('selected')); 130 - item.classList.add('selected'); 131 - 132 - // Store selection 133 108 this.selectedApp = { 134 - value: item.dataset.value, 135 - task: item.dataset.task, 136 - name: item.querySelector('.item-name').textContent 109 + value: option.value, 110 + task: option.dataset.task, 111 + name: option.textContent, 112 + win: option.dataset.win || null 137 113 }; 138 - 139 - this.elements.dropdownValue.textContent = this.selectedApp.name; 140 - this.closeDropdown(); 141 114 } 142 115 143 116 loadApp() { ··· 147 120 } 148 121 149 122 this.currentTask = this.selectedApp.task; 150 - 151 - // Update task displays 152 - this.elements.taskDescription.textContent = this.currentTask; 153 123 this.elements.currentTaskText.textContent = this.currentTask; 154 124 155 - // Load the iframe but keep it hidden 156 125 this.elements.iframe.src = this.selectedApp.value; 157 - this.elements.iframeStatus.textContent = 'Loading app...'; 158 126 this.session.setAppName(this.selectedApp.value); 159 127 this.session.setTask(this.currentTask); 160 128 } 161 129 162 - onIframeLoad() { 130 + async onIframeLoad() { 163 131 const src = this.elements.iframe.src; 164 - if (!src || src === 'about:blank') return; 132 + if (!src || src.includes('about:blank')) return; 165 133 166 - // Hide placeholder, show task briefing 167 134 this.elements.iframePlaceholder.classList.add('hidden'); 168 - this.elements.taskBriefing.classList.remove('hidden'); 169 - this.elements.iframeStatus.textContent = 'Ready to test'; 135 + this.elements.iframe.classList.remove('hidden'); 170 136 171 - // Hide the iframe until testing begins 172 - this.elements.iframe.style.visibility = 'hidden'; 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 + } 147 + } 148 + 149 + this.startCalibration(); 173 150 } 174 151 175 - beginTesting() { 176 - // Hide task briefing, show iframe 177 - this.elements.taskBriefing.classList.add('hidden'); 178 - this.elements.iframe.style.visibility = 'visible'; 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 + } 164 + } 165 + this.updateCalibrationProgress(); 166 + } 167 + 168 + onCalibrationPointClick(index, pointEl) { 169 + if (!this.calibrationClicks[index]) { 170 + this.calibrationClicks[index] = 0; 171 + } 172 + 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 + } 179 194 180 - // Enter testing mode - hide header and sidebar 181 - this.elements.appContainer.classList.add('testing-mode'); 195 + skipCalibration() { 196 + console.log('Skipping calibration'); 197 + this.beginTesting(); 198 + } 182 199 183 - // Attach tracker and start session 200 + beginTesting() { 201 + this.elements.calibrationScreen.classList.add('hidden'); 202 + this.elements.calibrationControls.classList.add('hidden'); 203 + 184 204 const attached = this.tracker.attachToIframe(this.elements.iframe); 185 - if (!attached) { 186 - console.warn('Could not attach tracker to iframe'); 187 - } 205 + if (!attached) console.warn('Could not attach tracker to iframe'); 188 206 189 207 this.session.start(); 190 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); 216 + } 217 + 218 + this._winStartTimer = setTimeout(() => { 219 + this._winStartTimer = null; 220 + this.setupWinCondition(); 221 + }, 1500); 191 222 } 192 223 193 - onTaskComplete(details) { 224 + setupWinCondition() { 225 + this.teardownWinCondition(); 226 + const winSpec = this.selectedApp?.win; 227 + if (!winSpec) { 228 + console.warn('[UXET] No win condition defined'); 229 + return; 230 + } 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 }; 241 + return; 242 + } 243 + 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; 308 + } 309 + this._winWatcher = null; 310 + if (this._winStartTimer) { 311 + clearTimeout(this._winStartTimer); 312 + this._winStartTimer = null; 313 + } 314 + } 315 + 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; } 324 + } 325 + 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) { 194 357 if (this.session.status !== 'recording') return; 358 + try { 359 + this.teardownWinCondition(); 360 + this.session.stop(); 361 + this.tracker.stop(); 195 362 196 - // Stop tracking 197 - this.session.stop(); 198 - this.tracker.stop(); 363 + document.querySelectorAll('video').forEach(v => { 364 + if (v.srcObject) { 365 + v.srcObject.getTracks().forEach(t => t.stop()); 366 + v.srcObject = null; 367 + } 368 + }); 199 369 200 - // Log completion 201 - this.tracker.logEvent('system', 'Task completed automatically'); 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 + } 202 379 203 - // Exit testing mode 204 - this.elements.appContainer.classList.remove('testing-mode'); 380 + if (this.gazeInitialized) { 381 + await this.gazeTracker.end(); 382 + this.gazeInitialized = false; 383 + } 205 384 206 - // Populate debrief stats 207 - const stats = this.tracker.getStats(); 208 - const timeFormatted = this.formatTime(this.session.elapsed); 385 + document.querySelectorAll('video').forEach(v => v.remove()); 386 + const wgContainer = document.getElementById('webgazerVideoContainer'); 387 + if (wgContainer) wgContainer.remove(); 209 388 210 - this.elements.debriefTime.textContent = timeFormatted; 211 - this.elements.debriefClicks.textContent = stats.mouse.clicks.toLocaleString(); 212 - this.elements.debriefKeys.textContent = stats.keyboard.totalKeys.toLocaleString(); 213 - this.elements.debriefDistance.textContent = Math.round(stats.mouse.distance).toLocaleString(); 214 - this.elements.debriefScrolls.textContent = stats.mouse.scrollEvents.toLocaleString(); 215 - this.elements.debriefVelocity.textContent = stats.mouse.avgVelocity.toLocaleString(); 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'); 216 408 217 - // Show debrief screen 218 - this.elements.debriefScreen.classList.remove('hidden'); 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'; 428 + return; 429 + } 430 + 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 + } 454 + 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 + })); 470 + } 471 + 472 + mappedPoints = mappedPoints.filter(p => p.px >= 0 && p.px <= w && p.py >= 0 && p.py <= h); 473 + 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; 481 + } 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]; 507 + } 508 + if (maxIntensity === 0) maxIntensity = 1; 509 + 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; 515 + } 516 + 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 + } 523 + 524 + heatCtx.putImageData(imageData, 0, 0); 525 + ctx.drawImage(heatCanvas, 0, 0); 526 + 527 + this.elements.heatmapLabel.textContent = `${mappedPoints.length} gaze points / ${this.gazeTracker.getStats().fixations} fixations`; 528 + } 529 + 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 + } 219 544 } 220 545 221 546 formatTime(ms) { ··· 225 550 return `${minutes}:${seconds.toString().padStart(2, '0')}`; 226 551 } 227 552 228 - resetSession() { 553 + async resetSession() { 554 + this.teardownWinCondition(); 229 555 this.session.reset(); 230 556 this.tracker.reset(); 231 - this.clearEventLog(); 557 + this.gazeTracker.reset(); 232 558 233 - // Reset UI state 234 - this.elements.appContainer.classList.remove('testing-mode'); 559 + if (this.gazeInitialized) { 560 + await this.gazeTracker.end(); 561 + this.gazeInitialized = false; 562 + } 563 + 564 + this.calibrationClicks = {}; 565 + this.calibratedPoints = 0; 566 + 235 567 this.elements.iframe.src = ''; 236 - this.elements.iframe.style.visibility = 'visible'; 568 + this.elements.iframe.classList.add('hidden'); 237 569 this.elements.iframePlaceholder.classList.remove('hidden'); 238 - this.elements.taskBriefing.classList.add('hidden'); 570 + 571 + this.elements.calibrationScreen.classList.add('hidden'); 239 572 this.elements.debriefScreen.classList.add('hidden'); 240 - this.elements.iframeStatus.textContent = 'No app loaded'; 241 - this.elements.currentTaskText.textContent = 'No task loaded'; 242 - this.elements.dropdownValue.textContent = 'Select an app...'; 573 + this.elements.currentTaskText.textContent = 'None'; 574 + this.elements.appSelect.value = ''; 243 575 this.selectedApp = null; 244 576 this.currentTask = ''; 245 577 246 - // Clear dropdown selection 247 - document.querySelectorAll('.dropdown-item').forEach(i => i.classList.remove('selected')); 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 + } 248 586 249 - this.updateStats(this.tracker.createInitialStats()); 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'; 250 590 } 251 591 252 592 exportData() { 253 593 const trackerData = this.tracker.exportData(); 594 + trackerData.gaze = this.gazeTracker.exportData(); 254 595 this.session.exportSession(trackerData); 255 596 } 256 597 257 598 updateSessionStatus(status) { 258 - const statusEl = this.elements.sessionStatus; 259 - statusEl.textContent = status.charAt(0).toUpperCase() + status.slice(1); 260 - statusEl.className = 'stat-value session-status ' + status; 261 - } 262 - 263 - updateStats(stats) { 264 - // Update internal stats (hidden from user during testing) 265 - this.elements.mousePosition.textContent = `${stats.mouse.x}, ${stats.mouse.y}`; 266 - this.elements.mouseMovements.textContent = stats.mouse.movements.toLocaleString(); 267 - this.elements.mouseClicks.textContent = stats.mouse.clicks.toLocaleString(); 268 - this.elements.mouseDistance.textContent = Math.round(stats.mouse.distance).toLocaleString(); 269 - this.elements.mouseVelocity.textContent = `${stats.mouse.avgVelocity} px/s`; 270 - this.elements.scrollEvents.textContent = stats.mouse.scrollEvents.toLocaleString(); 271 - 272 - this.elements.totalKeys.textContent = stats.keyboard.totalKeys.toLocaleString(); 273 - this.elements.keysPerMinute.textContent = stats.keyboard.keysPerMinute.toLocaleString(); 274 - this.elements.lastKey.textContent = stats.keyboard.lastKey || '--'; 275 - this.elements.backspaceCount.textContent = stats.keyboard.backspaces.toLocaleString(); 276 - 277 - this.elements.totalEvents.textContent = stats.totalEvents.toLocaleString(); 278 - } 279 - 280 - addEventToLog(event) { 281 - const log = this.elements.eventLog; 282 - 283 - const empty = log.querySelector('.log-empty'); 284 - if (empty) empty.remove(); 285 - 286 - const entry = document.createElement('div'); 287 - entry.className = 'log-entry'; 288 - 289 - const time = new Date(event.timestamp).toLocaleTimeString('en-US', { 290 - hour12: false, 291 - hour: '2-digit', 292 - minute: '2-digit', 293 - second: '2-digit' 294 - }); 295 - 296 - entry.innerHTML = ` 297 - <span class="log-time">${time}</span> 298 - <span class="log-type ${event.type}">${event.type}</span> 299 - <span class="log-message">${event.message}</span> 300 - `; 301 - 302 - log.insertBefore(entry, log.firstChild); 303 - 304 - while (log.children.length > 100) { 305 - log.removeChild(log.lastChild); 306 - } 307 - } 308 - 309 - clearEventLog() { 310 - this.elements.eventLog.innerHTML = '<div class="log-empty">No events recorded</div>'; 599 + this.elements.sessionStatus.textContent = status.charAt(0).toUpperCase() + status.slice(1); 311 600 } 312 601 } 313 602 314 - // Initialize app when DOM is ready 315 603 document.addEventListener('DOMContentLoaded', () => { 316 604 window.uxetApp = new UXETApp(); 317 605 });
+10 -1
testable-apps/example-app/index.html
··· 320 320 // Form submission 321 321 document.getElementById('test-form').addEventListener('submit', (e) => { 322 322 e.preventDefault(); 323 - alert('Form submitted! (This is just a demo)'); 323 + // Show success banner in the DOM 324 + let banner = document.getElementById('success-banner'); 325 + if (!banner) { 326 + banner = document.createElement('div'); 327 + banner.id = 'success-banner'; 328 + banner.style.cssText = 'position:fixed;top:0;left:0;right:0;padding:16px;background:#22c55e;color:white;text-align:center;font-weight:600;font-size:1.1rem;z-index:1000;animation:slideDown 0.3s ease;'; 329 + document.body.appendChild(banner); 330 + } 331 + banner.textContent = 'Form submitted successfully!'; 332 + banner.style.display = 'block'; 324 333 }); 325 334 </script> 326 335 </body>
-12
testable-apps/shop-app/index.html
··· 856 856 closeCart(); 857 857 checkoutSuccess.classList.add('active'); 858 858 859 - // Signal UXET that task is complete 860 - if (window.parent !== window) { 861 - window.parent.postMessage({ 862 - type: 'UXET_TASK_COMPLETE', 863 - details: { 864 - taskId: 'purchase', 865 - itemCount: cart.length, 866 - total: cart.reduce((sum, item) => sum + item.product.price * item.quantity, 0) 867 - } 868 - }, '*'); 869 - } 870 - 871 859 cart = []; 872 860 updateCart(); 873 861 });