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.

Merge pull request #1 from chriskalos/experiment/stupidstupid

Lots of experimental changes that ended up being fundamental to the improvement of the app

authored by

Chris and committed by
GitHub
85ae4df0 6122a95e

+3113 -1313
+2
.gitignore
··· 1 1 .DS_Store 2 + .uxet-server.log 3 + .uxet-server.pid
+18
.uxet-server.log
··· 1 + ::ffff:127.0.0.1 - - [29/Mar/2026 14:50:29] "GET / HTTP/1.1" 304 - 2 + ::ffff:127.0.0.1 - - [29/Mar/2026 14:50:29] "GET /js/main.js HTTP/1.1" 304 - 3 + ::ffff:127.0.0.1 - - [29/Mar/2026 14:50:29] "GET /index.css HTTP/1.1" 304 - 4 + ::ffff:127.0.0.1 - - [29/Mar/2026 14:50:29] "GET /testable-apps/shop-app/index.html?theme=dark HTTP/1.1" 304 - 5 + ::ffff:127.0.0.1 - - [29/Mar/2026 15:03:23] "GET / HTTP/1.1" 200 - 6 + ::ffff:127.0.0.1 - - [29/Mar/2026 15:03:23] "GET /index.css HTTP/1.1" 304 - 7 + ::ffff:127.0.0.1 - - [29/Mar/2026 15:03:23] "GET /js/main.js HTTP/1.1" 304 - 8 + ::ffff:127.0.0.1 - - [29/Mar/2026 15:03:23] "GET /js/tracker.js HTTP/1.1" 304 - 9 + ::ffff:127.0.0.1 - - [29/Mar/2026 15:03:23] "GET /js/gazeTracker.js HTTP/1.1" 304 - 10 + ::ffff:127.0.0.1 - - [29/Mar/2026 15:03:23] "GET /testable-apps/shop-app/index.html?theme=dark HTTP/1.1" 304 - 11 + ::ffff:127.0.0.1 - - [29/Mar/2026 15:03:24] "GET / HTTP/1.1" 304 - 12 + ::ffff:127.0.0.1 - - [29/Mar/2026 15:03:24] "GET /testable-apps/shop-app/index.html?theme=dark HTTP/1.1" 304 - 13 + ::ffff:127.0.0.1 - - [29/Mar/2026 15:04:46] "GET / HTTP/1.1" 304 - 14 + ::ffff:127.0.0.1 - - [29/Mar/2026 15:04:46] "GET /testable-apps/shop-app/index.html?theme=dark HTTP/1.1" 304 - 15 + ::ffff:127.0.0.1 - - [29/Mar/2026 15:07:41] "GET / HTTP/1.1" 200 - 16 + ::ffff:127.0.0.1 - - [29/Mar/2026 15:07:41] "GET /index.css HTTP/1.1" 200 - 17 + ::ffff:127.0.0.1 - - [29/Mar/2026 15:07:41] "GET /js/main.js HTTP/1.1" 200 - 18 + ::ffff:127.0.0.1 - - [29/Mar/2026 15:07:41] "GET /testable-apps/shop-app/index.html?theme=dark HTTP/1.1" 304 -
+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.
+458 -676
index.css
··· 1 - /* UXET - UX Testing Framework Styles */ 2 - 3 1 :root { 4 - /* Colors */ 5 - --bg-primary: #0a0a0f; 6 - --bg-secondary: #12121a; 7 - --bg-tertiary: #1a1a24; 8 - --bg-glass: rgba(26, 26, 36, 0.8); 2 + --font-sans: "Manrope", "Avenir Next", "Helvetica Neue", sans-serif; 3 + --font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; 4 + --space-1: 4px; 5 + --space-2: 8px; 6 + --space-3: 12px; 7 + --space-4: 16px; 8 + --space-5: 24px; 9 + --space-6: 32px; 10 + --radius-sm: 0px; 11 + --radius-md: 2px; 12 + --radius-lg: 2px; 13 + --shadow-sm: none; 14 + --shadow-md: 0 4px 20px rgba(0, 0, 0, 0.2); 15 + } 9 16 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; 17 + html[data-theme="light"] { 18 + --bg: #f4f5f6; 19 + --surface: #ffffff; 20 + --surface-2: #eaedf0; 21 + --surface-3: #dfe3e7; 22 + --text: #1d2125; 23 + --muted: #626f86; 24 + --border: #c1c7d0; 25 + --primary: #e54900; /* Engineering Orange */ 26 + --primary-hover: #cc4100; 27 + --secondary: #0052cc; 28 + --success: #0b875b; 29 + --danger: #de350b; 30 + --overlay: rgba(244, 245, 246, 0.96); 31 + --overlay-strong: rgba(244, 245, 246, 0.985); 32 + --placeholder: #b3bac5; 33 + --grid-pattern: rgba(193, 199, 208, 0.25); 34 + } 42 35 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); 36 + html[data-theme="dark"] { 37 + --bg: #090b0d; 38 + --surface: #101418; 39 + --surface-2: #161c22; 40 + --surface-3: #222b35; 41 + --text: #d4d8dd; 42 + --muted: #8b99a6; 43 + --border: #28343f; 44 + --primary: #f97316; /* Safety Orange */ 45 + --primary-hover: #ff8a3d; 46 + --secondary: #4c9aff; 47 + --success: #36b37e; 48 + --danger: #ff5630; 49 + --overlay: rgba(9, 11, 13, 0.96); 50 + --overlay-strong: rgba(9, 11, 13, 0.99); 51 + --placeholder: #3b4b5c; 52 + --grid-pattern: rgba(40, 52, 63, 0.35); 48 53 } 49 54 50 - /* Reset & Base */ 51 - *, 52 - *::before, 53 - *::after { 55 + * { 54 56 box-sizing: border-box; 55 - margin: 0; 56 - padding: 0; 57 + -webkit-font-smoothing: antialiased; 58 + -moz-osx-font-smoothing: grayscale; 57 59 } 58 60 59 - html, 60 - body { 61 - height: 100%; 62 - overflow: hidden; 61 + html, body { 62 + min-height: 100%; 63 63 } 64 64 65 65 body { 66 - font-family: var(--font-family); 67 - background: var(--bg-primary); 68 - color: var(--text-primary); 66 + margin: 0; 67 + background-color: var(--bg); 68 + background-image: linear-gradient(var(--grid-pattern) 1px, transparent 1px), 69 + linear-gradient(90deg, var(--grid-pattern) 1px, transparent 1px); 70 + background-size: 32px 32px; 71 + background-position: center top; 72 + color: var(--text); 73 + font-family: var(--font-sans); 69 74 line-height: 1.5; 70 75 } 71 76 72 - /* Utility Classes */ 73 - .hidden { 74 - display: none !important; 77 + body.session-active { 78 + overflow: hidden; 75 79 } 76 80 77 - /* App Container */ 78 - .app-container { 79 - display: flex; 80 - 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); 81 + button, select, input, textarea { 82 + font-family: var(--font-sans); 86 83 } 87 84 88 - /* Testing mode - hide header, full screen app */ 89 - .app-container.testing-mode .header { 90 - display: none; 85 + button, select, input, textarea { 86 + border: 1px solid var(--border); 87 + border-radius: var(--radius-sm); 88 + background: var(--surface); 89 + color: var(--text); 90 + transition: all 120ms ease; 91 91 } 92 92 93 - .app-container.testing-mode .iframe-header { 94 - display: none; 93 + button, select { 94 + min-height: 38px; 95 + padding: 0 var(--space-4); 95 96 } 96 97 97 - .app-container.testing-mode .main-content { 98 - padding: 0; 98 + button { 99 + cursor: pointer; 100 + font-weight: 600; 101 + text-transform: uppercase; 102 + font-size: 0.8rem; 103 + letter-spacing: 0.05em; 99 104 } 100 105 101 - .app-container.testing-mode .iframe-wrapper { 102 - border-radius: 0; 103 - border: none; 106 + button:hover:not(:disabled), 107 + select:hover:not(:disabled), 108 + input:hover:not(:disabled), 109 + textarea:hover:not(:disabled) { 110 + border-color: var(--primary); 111 + background: var(--surface-2); 104 112 } 105 113 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; 114 + button:disabled { 115 + opacity: 0.4; 116 + cursor: not-allowed; 117 + border-color: var(--border); 118 + background: var(--surface); 119 + color: var(--muted); 119 120 } 120 121 121 - .header-left { 122 - display: flex; 123 - align-items: center; 124 - gap: var(--spacing-md); 122 + button:focus-visible, 123 + select:focus-visible, 124 + input:focus-visible, 125 + textarea:focus-visible { 126 + outline: 2px solid var(--primary); 127 + outline-offset: 1px; 128 + border-color: var(--primary); 125 129 } 126 130 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; 131 + code, .stat-block strong, #session-timer { 132 + font-family: var(--font-mono); 137 133 } 138 134 139 - .logo-icon { 140 - font-size: 1.8rem; 141 - animation: pulse 2s ease-in-out infinite; 135 + #load-app-btn, 136 + #start-test-btn, 137 + #retry-calibration-btn, 138 + .checkout-btn, 139 + .add-to-cart-btn, 140 + .btn-primary, 141 + .subscription-form button, 142 + .action-row button[type="submit"] { 143 + background: var(--primary); 144 + border-color: var(--primary); 145 + color: #fff; 142 146 } 143 147 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 - } 148 + #load-app-btn:hover:not(:disabled), 149 + #start-test-btn:hover:not(:disabled), 150 + #retry-calibration-btn:hover:not(:disabled) { 151 + background: var(--primary-hover); 152 + border-color: var(--primary-hover); 156 153 } 157 154 158 - .tagline { 159 - color: var(--text-muted); 160 - font-size: 0.875rem; 161 - font-weight: 400; 155 + #app-container { 156 + min-height: 100vh; 162 157 } 163 158 164 - .header-center { 165 - flex: 1; 159 + #setup-shell { 166 160 display: flex; 161 + flex-direction: column; 167 162 justify-content: center; 168 163 align-items: center; 169 - gap: var(--spacing-sm); 170 - } 171 - 172 - .header-right { 173 - display: flex; 174 - align-items: center; 164 + min-height: 100vh; 165 + padding: var(--space-5); 166 + gap: var(--space-5); 167 + position: relative; 168 + z-index: 1; 175 169 } 176 170 177 - /* Custom Dropdown */ 178 - .custom-dropdown { 171 + #debrief-shell { 172 + min-height: 100vh; 173 + padding: var(--space-4) var(--space-5) var(--space-6); 174 + max-width: 1400px; 175 + margin: 0 auto; 179 176 position: relative; 180 - z-index: 1000; 181 177 } 182 178 183 - .dropdown-trigger { 179 + #controls { 180 + width: 100%; 181 + max-width: 800px; 182 + padding: var(--space-5); 184 183 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; 184 + flex-direction: column; 185 + gap: var(--space-5); 186 + position: relative; 187 + z-index: 10; 194 188 } 195 189 196 - .dropdown-trigger:hover { 197 - border-color: var(--accent-primary); 190 + #status-bar { 191 + width: 100%; 192 + max-width: 800px; 193 + padding: var(--space-4) var(--space-5); 194 + display: grid; 195 + grid-template-columns: repeat(4, minmax(0, 1fr)); 196 + gap: var(--space-4); 197 + border-top: 3px solid var(--primary); 198 198 } 199 199 200 - .dropdown-label { 201 - color: var(--text-secondary); 202 - font-size: 0.875rem; 203 - font-weight: 500; 200 + #controls, 201 + #status-bar, 202 + .overlay-card, 203 + .screen-card, 204 + #session-debug-drawer, 205 + .start-card, 206 + #calibration-hud, 207 + .debrief-panel { 208 + background: var(--surface); 209 + border: 1px solid var(--border); 210 + border-radius: var(--radius-lg); 211 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); 204 212 } 205 213 206 - .dropdown-value { 207 - flex: 1; 208 - color: var(--text-primary); 209 - font-size: 0.875rem; 210 - text-align: left; 214 + .toolbar, 215 + .control-row { 216 + display: flex; 217 + align-items: flex-end; 218 + justify-content: space-between; 219 + gap: var(--space-4); 220 + flex-wrap: wrap; 211 221 } 212 222 213 - .dropdown-arrow { 214 - color: var(--text-muted); 215 - font-size: 0.65rem; 216 - transition: transform var(--transition-fast); 223 + .toolbar-copy h1, 224 + .debrief-header h2, 225 + .start-card h2 { 226 + margin: 0; 227 + font-size: 1.5rem; 228 + font-weight: 700; 229 + text-transform: uppercase; 230 + letter-spacing: 0.03em; 217 231 } 218 232 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); 233 + .toolbar-copy p, 234 + .debrief-header p, 235 + .start-card p, 236 + .debug-copy { 237 + margin: var(--space-2) 0 0; 238 + color: var(--muted); 239 + font-size: 0.95rem; 237 240 } 238 241 239 - .custom-dropdown.open .dropdown-menu { 240 - opacity: 1; 241 - visibility: visible; 242 - transform: translateY(0); 242 + .toolbar-actions, 243 + .primary-actions { 244 + display: flex; 245 + align-items: center; 246 + gap: var(--space-3); 247 + flex-wrap: wrap; 243 248 } 244 249 245 - .dropdown-item { 250 + .field { 246 251 display: flex; 247 252 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 + gap: var(--space-2); 254 + flex: 1; 255 + min-width: min(320px, 100%); 253 256 } 254 257 255 - .dropdown-item:last-child { 256 - border-bottom: none; 258 + .field span, 259 + .label { 260 + font-size: 0.75rem; 261 + font-weight: 700; 262 + color: var(--muted); 263 + text-transform: uppercase; 264 + letter-spacing: 0.05em; 265 + font-family: var(--font-mono); 257 266 } 258 267 259 - .dropdown-item:hover { 260 - background: var(--bg-tertiary); 268 + #app-select { 269 + width: 100%; 261 270 } 262 271 263 - .dropdown-item.selected { 264 - background: rgba(99, 102, 241, 0.15); 272 + .global-theme-container { 273 + position: fixed; 274 + inset: 0; 275 + max-width: 1400px; 276 + margin: 0 auto; 277 + pointer-events: none; 278 + z-index: 90; 265 279 } 266 280 267 - .item-name { 268 - font-weight: 500; 269 - color: var(--text-primary); 281 + .global-theme-container .theme-toggle { 282 + pointer-events: auto; 270 283 } 271 284 272 - .item-task { 273 - font-size: 0.8rem; 274 - color: var(--text-muted); 275 - } 276 - 277 - /* Buttons */ 278 - .btn { 285 + .theme-toggle { 286 + position: absolute; 287 + top: var(--space-5); 288 + right: var(--space-5); 279 289 display: inline-flex; 280 - 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; 290 + padding: 2px; 291 + border: 1px solid var(--border); 287 292 border-radius: var(--radius-sm); 288 - cursor: pointer; 289 - transition: all var(--transition-fast); 293 + background: var(--surface-2); 294 + z-index: 50; 290 295 } 291 296 292 - .btn:disabled { 293 - opacity: 0.5; 294 - cursor: not-allowed; 297 + .theme-option { 298 + min-width: 72px; 299 + border: none; 300 + background: transparent; 301 + color: var(--muted); 295 302 } 296 303 297 - .btn-icon { 298 - font-size: 0.75rem; 304 + .theme-option.active { 305 + background: var(--surface); 306 + color: var(--text); 307 + box-shadow: 0 1px 3px rgba(0,0,0,0.1); 299 308 } 300 309 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); 310 + .debug-shell { 311 + border-top: 1px dashed var(--border); 312 + padding-top: var(--space-4); 313 + display: flex; 314 + flex-direction: column; 315 + gap: var(--space-3); 316 + width: 100%; 317 + position: relative; 305 318 } 306 319 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); 320 + .debug-toggle { 321 + align-self: flex-start; 322 + background: transparent; 323 + border-color: var(--border); 324 + color: var(--text); 311 325 } 312 326 313 - .btn-secondary { 314 - background: var(--bg-tertiary); 315 - color: var(--text-primary); 316 - border: 1px solid var(--border-color); 327 + .debug-toggle:hover { 328 + background: var(--surface-2); 329 + border-color: var(--primary); 330 + color: var(--text); 317 331 } 318 332 319 - .btn-secondary:hover:not(:disabled) { 320 - background: var(--bg-secondary); 321 - border-color: var(--text-muted); 333 + #debug-panel { 334 + display: flex; 335 + flex-wrap: wrap; 336 + gap: var(--space-3); 337 + align-items: center; 338 + padding: var(--space-3); 339 + background: var(--surface-2); 340 + border-radius: var(--radius-md); 341 + border: 1px solid var(--border); 342 + position: absolute; 343 + top: calc(100% + var(--space-2)); 344 + left: 0; 345 + z-index: 20; 346 + box-shadow: var(--shadow-md); 322 347 } 323 348 324 - .btn-accent { 325 - background: linear-gradient(135deg, var(--accent-secondary), #06b6d4); 326 - color: var(--bg-primary); 327 - font-weight: 600; 349 + .debug-checkbox { 350 + display: inline-flex; 351 + align-items: center; 352 + gap: var(--space-2); 353 + color: var(--text); 354 + font-size: 0.9rem; 328 355 } 329 356 330 - .btn-accent:hover:not(:disabled) { 331 - background: linear-gradient(135deg, #67e8f9, var(--accent-secondary)); 332 - transform: translateY(-1px); 357 + .debug-checkbox input { 358 + width: 16px; 359 + height: 16px; 360 + accent-color: var(--primary); 333 361 } 334 362 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; 363 + #status-bar > div, 364 + .stat-block { 353 365 display: flex; 354 366 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); 367 + gap: var(--space-1); 362 368 } 363 369 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); 370 + #status-bar strong, 371 + .stat-block strong { 372 + font-size: 1.1rem; 373 + color: var(--text); 371 374 } 372 375 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; 387 - position: relative; 388 - background: var(--bg-primary); 376 + #session-stage { 377 + position: fixed; 378 + inset: 0; 379 + z-index: 100; 380 + background: #000; 389 381 } 390 382 391 - .iframe-placeholder { 383 + #test-iframe, 384 + #calibration-screen, 385 + #start-curtain, 386 + #start-overlay, 387 + #calibration-failure-overlay { 392 388 position: absolute; 393 389 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; 401 - } 402 - 403 - .placeholder-content { 404 - 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; 424 390 } 425 391 426 392 #test-iframe { 427 - width: 100%; 428 - height: 100%; 393 + width: 100vw; 394 + height: 100vh; 429 395 border: none; 430 - background: white; 396 + background: #fff; 431 397 } 432 398 433 - /* Task Briefing Screen */ 434 - .task-briefing { 399 + .session-debug-toggle { 435 400 position: absolute; 436 - 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; 457 - } 458 - 459 - @keyframes slideUp { 460 - from { 461 - opacity: 0; 462 - transform: translateY(20px); 463 - } 464 - 465 - to { 466 - opacity: 1; 467 - transform: translateY(0); 468 - } 401 + top: var(--space-4); 402 + right: var(--space-4); 403 + z-index: 7; 404 + background: var(--surface); 469 405 } 470 406 471 - .briefing-header { 472 - background: linear-gradient(135deg, var(--accent-primary), #4f46e5); 473 - padding: var(--spacing-lg); 474 - text-align: center; 407 + #session-debug-drawer { 408 + position: absolute; 409 + top: 60px; 410 + right: var(--space-4); 411 + z-index: 7; 412 + width: min(320px, calc(100vw - 32px)); 413 + padding: var(--space-4); 475 414 } 476 415 477 - .briefing-icon { 478 - font-size: 2.5rem; 479 - display: block; 480 - margin-bottom: var(--spacing-sm); 416 + .session-debug-header { 417 + margin-bottom: var(--space-4); 418 + padding-bottom: var(--space-2); 419 + border-bottom: 1px solid var(--border); 420 + font-weight: 700; 421 + font-family: var(--font-mono); 422 + text-transform: uppercase; 423 + font-size: 0.85rem; 481 424 } 482 425 483 - .briefing-header h2 { 484 - color: white; 485 - font-size: 1.5rem; 486 - font-weight: 600; 426 + .session-debug-actions { 427 + display: grid; 428 + gap: var(--space-3); 487 429 } 488 430 489 - .briefing-body { 490 - padding: var(--spacing-lg); 431 + #calibration-screen { 432 + z-index: 2; 491 433 } 492 434 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; 435 + #calibration-veil { 436 + position: absolute; 437 + inset: 0; 438 + background: rgba(0, 0, 0, 0.4); 439 + backdrop-filter: blur(2px); 503 440 } 504 441 505 - .briefing-info { 506 - display: flex; 507 - flex-direction: column; 508 - gap: var(--spacing-sm); 509 - } 510 - 511 - .info-item { 442 + #calibration-hud { 443 + position: absolute; 444 + top: var(--space-4); 445 + left: var(--space-4); 446 + right: var(--space-4); 447 + z-index: 3; 512 448 display: flex; 449 + flex-wrap: wrap; 450 + gap: var(--space-3) var(--space-5); 513 451 align-items: center; 514 - gap: var(--spacing-sm); 515 - color: var(--text-secondary); 516 - font-size: 0.875rem; 452 + padding: var(--space-3) var(--space-5); 517 453 } 518 454 519 - .info-icon { 520 - font-size: 1rem; 521 - opacity: 0.7; 522 - } 523 - 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); 455 + #calibration-hud span { 456 + font-family: var(--font-mono); 457 + font-size: 0.9rem; 529 458 } 530 459 531 - .ready-text { 532 - color: var(--text-muted); 533 - font-size: 0.875rem; 534 - margin-bottom: var(--spacing-md); 460 + #calibration-stage { 461 + position: absolute; 462 + inset: 0; 535 463 } 536 464 537 - /* Debrief Screen */ 538 - .debrief-screen { 465 + #calibration-target { 539 466 position: absolute; 540 - inset: 0; 467 + width: 48px; 468 + height: 48px; 469 + transform: translate(-50%, -50%); 541 470 display: flex; 542 471 align-items: center; 543 472 justify-content: center; 544 - background: rgba(10, 10, 15, 0.95); 545 - z-index: 10; 546 - padding: var(--spacing-xl); 547 - } 548 - 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 { 574 - color: white; 575 - font-size: 1.75rem; 473 + border: 2px solid var(--primary); 474 + border-radius: 0; 475 + background: rgba(0, 0, 0, 0.8); 476 + color: var(--primary); 477 + font-family: var(--font-mono); 576 478 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; 479 + box-shadow: 0 0 15px rgba(229, 73, 0, 0.4); 480 + transition: transform 100ms ease, background 100ms ease; 481 + cursor: crosshair; 583 482 } 584 483 585 - .debrief-body { 586 - padding: var(--spacing-lg); 587 - } 588 - 589 - .debrief-stat-group { 590 - margin-bottom: var(--spacing-lg); 484 + #calibration-target:active { 485 + background: var(--primary); 486 + color: #000; 487 + transform: translate(-50%, -50%) scale(0.9); 591 488 } 592 489 593 - .debrief-stat-group:last-child { 594 - margin-bottom: 0; 490 + #start-curtain { 491 + z-index: 3; 492 + background: var(--surface); 595 493 } 596 494 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 { 495 + #start-overlay, 496 + #calibration-failure-overlay { 497 + z-index: 4; 606 498 display: grid; 607 - grid-template-columns: repeat(3, 1fr); 608 - gap: var(--spacing-md); 499 + place-items: center; 500 + padding: var(--space-5); 501 + background: url('data:image/svg+xml;utf8,<svg width="40" height="40" xmlns="http://www.w3.org/2000/svg"><path d="M0 40 L40 0 H20 L0 20 Z" fill="rgba(80,80,80,0.05)"/></svg>'); 609 502 } 610 503 611 - .debrief-stat { 612 - background: var(--bg-tertiary); 613 - padding: var(--spacing-md); 614 - border-radius: var(--radius-md); 504 + .start-card { 505 + width: min(500px, calc(100vw - 32px)); 506 + padding: var(--space-6); 615 507 text-align: center; 508 + border-top: 4px solid var(--primary); 616 509 } 617 510 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); 511 + .start-card h2 { 512 + margin-bottom: var(--space-3); 625 513 } 626 514 627 - .debrief-stat-label { 628 - font-size: 0.75rem; 629 - color: var(--text-muted); 630 - text-transform: uppercase; 631 - letter-spacing: 0.5px; 515 + .start-note { 516 + margin-bottom: var(--space-5); 632 517 } 633 518 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); 519 + .failure-card { 520 + border-top-color: var(--danger); 641 521 } 642 522 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); 523 + #debrief-screen { 524 + display: grid; 525 + gap: var(--space-4); 651 526 } 652 527 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); 528 + .debrief-header { 529 + display: flex; 530 + justify-content: space-between; 531 + gap: var(--space-4); 532 + align-items: flex-end; 533 + flex-wrap: wrap; 534 + padding-bottom: var(--space-4); 535 + border-bottom: 2px solid var(--border); 660 536 } 661 537 662 - .sidebar-section:hover { 663 - border-color: rgba(255, 255, 255, 0.12); 664 - } 665 - 666 - .section-title { 538 + #stats-container { 667 539 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); 540 + flex-wrap: wrap; 541 + gap: var(--space-4); 542 + justify-content: space-between; 676 543 } 677 544 678 - .section-icon { 679 - font-size: 1rem; 680 - } 681 - 682 - .stats-grid { 683 - display: grid; 684 - grid-template-columns: repeat(2, 1fr); 685 - gap: var(--spacing-sm); 686 - } 687 - 688 - .stat-item { 689 - background: var(--bg-tertiary); 690 - padding: var(--spacing-sm) var(--spacing-md); 691 - border-radius: var(--radius-sm); 545 + .stat-block { 692 546 display: flex; 693 547 flex-direction: column; 694 - gap: 2px; 548 + gap: var(--space-1); 549 + padding: 0 var(--space-3); 550 + border-left: 2px solid var(--border); 551 + flex: 1; 552 + min-width: 100px; 695 553 } 696 554 697 - .stat-item.full-width { 698 - grid-column: span 2; 699 - flex-direction: row; 700 - justify-content: space-between; 701 - align-items: center; 555 + .stat-block:first-child { 556 + border-left: none; 557 + padding-left: 0; 702 558 } 703 559 704 - .stat-label { 705 - font-size: 0.7rem; 706 - color: var(--text-muted); 560 + .stat-block span { 561 + font-size: 0.75rem; 562 + font-weight: 700; 563 + color: var(--muted); 707 564 text-transform: uppercase; 708 - letter-spacing: 0.5px; 565 + font-family: var(--font-mono); 709 566 } 710 567 711 - .stat-value { 712 - font-size: 1rem; 713 - font-weight: 600; 714 - color: var(--accent-secondary); 715 - font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; 568 + #screen-gallery { 569 + display: grid; 570 + gap: var(--space-5); 716 571 } 717 572 718 - .session-status { 719 - padding: 2px 8px; 720 - border-radius: var(--radius-sm); 721 - font-size: 0.8rem; 573 + .screen-card { 574 + padding: var(--space-5); 722 575 } 723 576 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)); 756 - } 757 - 758 - .current-task-text { 759 - color: var(--text-primary); 760 - font-size: 0.9rem; 761 - line-height: 1.5; 762 - } 763 - 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 { 577 + .screen-card-header { 781 578 display: flex; 579 + justify-content: space-between; 580 + gap: var(--space-4); 782 581 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); 582 + margin-bottom: var(--space-4); 583 + padding-bottom: var(--space-3); 584 + border-bottom: 1px dashed var(--border); 787 585 } 788 586 789 - .log-entry:last-child { 790 - border-bottom: none; 587 + .screen-card-header h4 { 588 + margin: 0; 589 + font-family: var(--font-mono); 590 + font-size: 1rem; 591 + font-weight: 700; 791 592 } 792 593 793 - .log-time { 794 - color: var(--text-muted); 795 - font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; 796 - white-space: nowrap; 594 + .screen-card-stats { 595 + display: grid; 596 + justify-items: end; 597 + gap: var(--space-1); 598 + font-family: var(--font-mono); 599 + font-size: 0.85rem; 797 600 } 798 601 799 - .log-type { 800 - padding: 1px 6px; 801 - border-radius: 3px; 802 - font-size: 0.65rem; 803 - font-weight: 600; 804 - text-transform: uppercase; 602 + .screen-card-canvas-wrap { 603 + overflow: hidden; 604 + border: 1px solid var(--border); 605 + background: #000; /* Deep backdrop for heatmap */ 805 606 } 806 607 807 - .log-type.mouse { 808 - background: rgba(99, 102, 241, 0.2); 809 - color: var(--accent-primary); 608 + .screen-heatmap-canvas { 609 + display: block; 610 + width: 100%; 611 + height: auto; 612 + opacity: 0.9; 810 613 } 811 614 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); 615 + .screen-card-fallback { 616 + min-height: 200px; 617 + display: grid; 618 + place-items: center; 619 + text-align: center; 620 + padding: var(--space-5); 621 + color: var(--muted); 622 + background: var(--surface-2); 623 + font-family: var(--font-mono); 624 + border: 1px dashed var(--border); 825 625 } 826 626 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; 841 - } 842 - 843 - ::-webkit-scrollbar-thumb { 844 - background: var(--text-muted); 845 - border-radius: 3px; 627 + .hidden { 628 + display: none !important; 846 629 } 847 630 848 - ::-webkit-scrollbar-thumb:hover { 849 - background: var(--text-secondary); 631 + @media (max-width: 980px) { 632 + #status-bar { 633 + grid-template-columns: repeat(2, minmax(0, 1fr)); 634 + } 850 635 } 851 636 852 - /* Responsive */ 853 - @media (max-width: 768px) { 854 - .header { 855 - flex-wrap: wrap; 856 - gap: var(--spacing-md); 637 + @media (max-width: 720px) { 638 + #status-bar { 639 + grid-template-columns: 1fr; 857 640 } 858 - 859 - .header-center { 860 - order: 3; 861 - flex-basis: 100%; 641 + 642 + #setup-shell { 643 + justify-content: flex-start; 862 644 } 863 - 864 - .dropdown-trigger { 865 - width: 100%; 645 + 646 + .screen-card-header { 647 + flex-direction: column; 866 648 } 867 - 868 - .debrief-stats { 869 - grid-template-columns: 1fr; 649 + 650 + .screen-card-stats { 651 + justify-items: start; 870 652 } 871 - } 653 + }
+134 -226
index.html
··· 1 1 <!DOCTYPE html> 2 - <html lang="en"> 2 + <html lang="en" data-theme="dark"> 3 3 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</title> 8 + <link rel="preconnect" href="https://fonts.googleapis.com"> 9 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 10 + <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Manrope:wght@400;500;700&display=swap" rel="stylesheet"> 8 11 <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"> 12 + <script src="https://cdn.jsdelivr.net/npm/webgazer@3.4.0/dist/webgazer.js" crossorigin="anonymous"></script> 13 + <script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js" crossorigin="anonymous"></script> 10 14 </head> 11 15 12 16 <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> 17 + <div id="app-container"> 18 + <div class="global-theme-container"> 19 + <div class="theme-toggle" role="group" aria-label="Theme selection"> 20 + <button id="theme-dark-btn" class="theme-option" type="button">Dark</button> 21 + <button id="theme-light-btn" class="theme-option" type="button">Light</button> 50 22 </div> 51 - </header> 23 + </div> 52 24 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> 25 + <section id="debrief-shell" class="hidden"> 26 + <section id="debrief-screen"> 27 + <header class="debrief-header"> 28 + <div> 29 + <h2>Session Debrief</h2> 30 + <p id="screen-gallery-label">Heatmaps will appear here after a test completes.</p> 69 31 </div> 32 + </header> 70 33 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> 34 + <section class="overlay-card debrief-panel"> 35 + <div id="stats-container"> 36 + <div class="stat-block"><span>Time</span><strong id="debrief-time">00:00:00</strong></div> 37 + <div class="stat-block"><span>Clicks</span><strong id="debrief-clicks">0</strong></div> 38 + <div class="stat-block"><span>Keys</span><strong id="debrief-keys">0</strong></div> 39 + <div class="stat-block"><span>Mouse Distance</span><strong id="debrief-distance">0</strong></div> 40 + <div class="stat-block"><span>Scroll Events</span><strong id="debrief-scrolls">0</strong></div> 41 + <div class="stat-block"><span>Avg Velocity</span><strong id="debrief-velocity">0</strong></div> 42 + <div class="stat-block"><span>Gaze Points</span><strong id="debrief-gaze-points">0</strong></div> 43 + <div class="stat-block"><span>Fixations</span><strong id="debrief-fixations">0</strong></div> 44 + <div class="stat-block"><span>Avg Fixation</span><strong id="debrief-fixation-duration">0</strong></div> 98 45 </div> 46 + </section> 99 47 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> 48 + <div id="screen-gallery"></div> 49 + </section> 50 + </section> 51 + 52 + <section id="setup-shell"> 53 + <header id="controls"> 54 + <div class="toolbar"> 55 + <div class="toolbar-copy"> 56 + <h1>UXET</h1> 57 + <p>Prepare a session, preload the app, and capture gaze data without changing the test flow.</p> 153 58 </div> 154 59 155 - <iframe id="test-iframe" src="" title="Test Application"></iframe> 156 60 </div> 157 - </div> 158 61 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> 62 + <div class="control-row"> 63 + <label class="field field-app" for="app-select"> 64 + <span>App under test</span> 65 + <select id="app-select"> 66 + <option value="">Select an app...</option> 67 + <option value="testable-apps/shop-app/index.html" data-task="Find and purchase a blue t-shirt" 68 + data-win="selector:.checkout-success.active">ShopEasy Store</option> 69 + <option value="testable-apps/example-app/index.html" 70 + data-task="Fill out the contact form with your details" 71 + data-win="text:Form submitted successfully"> 72 + Example Form App 73 + </option> 74 + <option value="testable-apps/long-page-app/index.html" 75 + data-task="Review the comparison sections and subscribe at the bottom of the page" 76 + data-win="selector:#success-banner">Long Page Demo</option> 77 + </select> 78 + </label> 168 79 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> 80 + <div class="primary-actions"> 81 + <button id="load-app-btn" type="button">Load App</button> 82 + <button id="export-btn" type="button" disabled>Export Data</button> 83 + <button id="reset-btn" type="button">Reset</button> 199 84 </div> 200 85 </div> 201 86 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> 87 + <div class="debug-shell"> 88 + <button id="debug-toggle-btn" class="debug-toggle" type="button" aria-expanded="false">Debug Controls</button> 89 + <div id="debug-panel" class="hidden"> 90 + <button id="debug-skip-calibration" type="button">Skip Calibration</button> 91 + <label class="debug-checkbox"> 92 + <input id="debug-mouse-gaze-toggle" type="checkbox"> 93 + <span>Use mouse as gaze</span> 94 + </label> 95 + <button id="debug-exit-test" type="button" disabled>End Test</button> 96 + <p class="debug-copy">Session exit shortcut: <code>Shift+Escape</code></p> 224 97 </div> 225 98 </div> 99 + </header> 100 + 101 + <section id="status-bar" aria-live="polite"> 102 + <div><span class="label">State</span><strong id="session-status">idle</strong></div> 103 + <div><span class="label">Time</span><strong id="session-timer">00:00:00</strong></div> 104 + <div><span class="label">Task</span><strong id="current-task-text">None</strong></div> 105 + <div><span class="label">Message</span><strong id="session-message">Select an app to begin.</strong></div> 106 + </section> 107 + 226 108 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> 109 + </section> 110 + 111 + <section id="session-stage" class="hidden" aria-live="polite"> 112 + <iframe id="test-iframe" class="hidden" title="Test Application"></iframe> 243 113 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> 114 + <button id="session-debug-toggle-btn" class="session-debug-toggle hidden" type="button">Debug</button> 115 + <aside id="session-debug-drawer" class="hidden"> 116 + <div class="session-debug-header"> 117 + <strong>Debug Controls</strong> 118 + </div> 119 + <div class="session-debug-actions"> 120 + <button id="session-debug-skip-calibration" type="button">Skip Calibration</button> 121 + <label class="debug-checkbox"> 122 + <input id="session-debug-mouse-gaze-toggle" type="checkbox"> 123 + <span>Use mouse as gaze</span> 124 + </label> 125 + <button id="session-debug-exit-test" type="button">End Test</button> 252 126 </div> 253 127 </aside> 254 - </main> 128 + 129 + <section id="calibration-screen" class="hidden"> 130 + <div id="calibration-veil"></div> 131 + <div id="calibration-hud"> 132 + <span><strong id="calibration-progress">1 / 9</strong> points</span> 133 + <span><strong id="calibration-clicks">0 / 3</strong> clicks</span> 134 + <span id="calibration-quality">Pending</span> 135 + <span id="calibration-feedback">Waiting for first point</span> 136 + <span id="calibration-instruction">Look at point 1 and click it 3 times.</span> 137 + </div> 138 + <div id="calibration-stage"> 139 + <button id="calibration-target" type="button">1</button> 140 + </div> 141 + </section> 142 + 143 + <section id="start-curtain" class="hidden"></section> 144 + 145 + <section id="start-overlay" class="hidden"> 146 + <div class="start-card"> 147 + <h2>Task Ready</h2> 148 + <p id="start-overlay-task">Task will appear here.</p> 149 + <p class="start-note">The app is loaded and hidden. It will appear when recording starts.</p> 150 + <button id="start-test-btn" type="button">Start Test</button> 151 + </div> 152 + </section> 153 + 154 + <section id="calibration-failure-overlay" class="hidden"> 155 + <div class="start-card failure-card"> 156 + <h2>Calibration Retry Required</h2> 157 + <p id="calibration-failure-summary">Average error summary will appear here.</p> 158 + <p class="start-note">Keep your head still, look directly at each target, then retry.</p> 159 + <button id="retry-calibration-btn" type="button">Retry Calibration</button> 160 + </div> 161 + </section> 162 + </section> 255 163 </div> 256 164 257 165 <script type="module" src="js/main.js"></script> 258 166 </body> 259 167 260 - </html> 168 + </html>
+192
js/calibration.js
··· 1 + const CALIBRATION_SEQUENCE = [ 2 + { id: 'center', x: 0.5, y: 0.5 }, 3 + { id: 'top-left', x: 0, y: 0 }, 4 + { id: 'top-right', x: 1, y: 0 }, 5 + { id: 'bottom-left', x: 0, y: 1 }, 6 + { id: 'bottom-right', x: 1, y: 1 }, 7 + { id: 'top', x: 0.5, y: 0 }, 8 + { id: 'left', x: 0, y: 0.5 }, 9 + { id: 'right', x: 1, y: 0.5 }, 10 + { id: 'bottom', x: 0.5, y: 1 } 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, getViewportRect, getSafeAreaInsets }) { 22 + this.gazeTracker = gazeTracker; 23 + this.elements = elements; 24 + this.getViewportRect = getViewportRect; 25 + this.getSafeAreaInsets = getSafeAreaInsets; 26 + this.sequence = CALIBRATION_SEQUENCE; 27 + this.clicksPerPoint = 3; 28 + this.sampleWindowMs = 600; 29 + this.maxPointError = 120; 30 + this.maxAverageError = 80; 31 + this.currentIndex = 0; 32 + this.currentClicks = 0; 33 + this.pointResults = []; 34 + this.currentPointStart = 0; 35 + this.onStateChange = null; 36 + this.onComplete = null; 37 + } 38 + 39 + start() { 40 + this.currentIndex = 0; 41 + this.currentClicks = 0; 42 + this.pointResults = this.sequence.map((point) => ({ 43 + id: point.id, 44 + error: null, 45 + attempts: 0, 46 + passed: false 47 + })); 48 + this.currentPointStart = Date.now(); 49 + this.elements.calibrationQuality.textContent = 'Pending'; 50 + this.elements.calibrationFeedback.textContent = 'Waiting for first point'; 51 + this.updateUi(); 52 + this.emitState(); 53 + } 54 + 55 + async handleTargetClick() { 56 + const point = this.sequence[this.currentIndex]; 57 + if (!point) { 58 + return; 59 + } 60 + 61 + this.currentClicks += 1; 62 + this.updateUi(); 63 + 64 + if (this.currentClicks < this.clicksPerPoint) { 65 + return; 66 + } 67 + 68 + const pointResult = this.pointResults[this.currentIndex]; 69 + pointResult.attempts += 1; 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.onComplete) { 110 + this.onComplete(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 viewport = this.getViewportRect(); 142 + const insets = this.getSafeAreaInsets(); 143 + const width = Math.max(0, viewport.width - insets.left - insets.right); 144 + const height = Math.max(0, viewport.height - insets.top - insets.bottom); 145 + 146 + return { 147 + x: viewport.left + insets.left + (width * point.x), 148 + y: viewport.top + insets.top + (height * point.y) 149 + }; 150 + } 151 + 152 + updateUi() { 153 + const point = this.sequence[this.currentIndex]; 154 + const currentNumber = Math.min(this.currentIndex + 1, this.sequence.length); 155 + this.elements.calibrationProgress.textContent = `${currentNumber} / ${this.sequence.length}`; 156 + this.elements.calibrationClicks.textContent = `${this.currentClicks} / ${this.clicksPerPoint}`; 157 + this.elements.calibrationTarget.style.display = point ? 'flex' : 'none'; 158 + 159 + if (point) { 160 + const target = this.getTargetPixelPosition(point); 161 + this.elements.calibrationTarget.style.left = `${target.x}px`; 162 + this.elements.calibrationTarget.style.top = `${target.y}px`; 163 + this.elements.calibrationTarget.textContent = String(currentNumber); 164 + this.elements.calibrationInstruction.textContent = `Look at point ${currentNumber} and click it ${this.clicksPerPoint} times.`; 165 + } 166 + } 167 + 168 + getQualityLabel(error) { 169 + if (!Number.isFinite(error)) { 170 + return 'Needs retry'; 171 + } 172 + if (error <= 45) { 173 + return 'Excellent'; 174 + } 175 + if (error <= 80) { 176 + return 'Good'; 177 + } 178 + return 'Needs retry'; 179 + } 180 + 181 + emitState(result = null) { 182 + if (this.onStateChange) { 183 + this.onStateChange({ 184 + currentIndex: this.currentIndex, 185 + totalPoints: this.sequence.length, 186 + currentClicks: this.currentClicks, 187 + quality: this.elements.calibrationQuality.textContent, 188 + result 189 + }); 190 + } 191 + } 192 + }
+173
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 + function getUsablePoints(screen) { 89 + const width = screen.screenshot.width || screen.document.width || 1200; 90 + const height = screen.screenshot.height || screen.document.height || 800; 91 + return screen.gazePoints 92 + .filter((point) => point.inIframe) 93 + .filter((point) => point.docX >= 0 && point.docX <= width && point.docY >= 0 && point.docY <= height); 94 + } 95 + 96 + function buildFallback(text) { 97 + const fallback = document.createElement('div'); 98 + fallback.className = 'screen-card-fallback'; 99 + fallback.textContent = text; 100 + return fallback; 101 + } 102 + 103 + export class DebriefRenderer { 104 + constructor({ galleryElement, labelElement }) { 105 + this.galleryElement = galleryElement; 106 + this.labelElement = labelElement; 107 + } 108 + 109 + async render(screens) { 110 + this.galleryElement.innerHTML = ''; 111 + 112 + if (!screens.length) { 113 + this.labelElement.textContent = 'No tracked screens were captured.'; 114 + return; 115 + } 116 + 117 + const renderableScreens = screens.filter((screen) => { 118 + const hasScreenshot = screen.screenshot.status === 'ready' && screen.screenshot.dataUrl; 119 + const usablePoints = getUsablePoints(screen); 120 + return hasScreenshot || usablePoints.length > 0; 121 + }); 122 + 123 + if (!renderableScreens.length) { 124 + this.labelElement.textContent = 'No renderable screenshots or gaze results were produced for this session.'; 125 + return; 126 + } 127 + 128 + const totalPoints = screens.reduce((sum, screen) => sum + screen.gazePoints.length, 0); 129 + this.labelElement.textContent = `${renderableScreens.length} screens captured · ${totalPoints} gaze points`; 130 + 131 + for (const screen of renderableScreens) { 132 + const card = document.createElement('article'); 133 + card.className = 'screen-card'; 134 + 135 + const header = document.createElement('div'); 136 + header.className = 'screen-card-header'; 137 + header.innerHTML = ` 138 + <div> 139 + <h4>${screen.title || screen.key}</h4> 140 + <p>${screen.key}</p> 141 + </div> 142 + <div class="screen-card-stats"> 143 + <span>${screen.gazePoints.length} gaze points</span> 144 + <span>${screen.fixationCount} fixations</span> 145 + </div> 146 + `; 147 + 148 + const canvasWrap = document.createElement('div'); 149 + canvasWrap.className = 'screen-card-canvas-wrap'; 150 + const hasScreenshot = screen.screenshot.status === 'ready' && screen.screenshot.dataUrl; 151 + const usablePoints = getUsablePoints(screen); 152 + 153 + if (hasScreenshot) { 154 + try { 155 + const canvas = await renderHeatmapCanvas(screen); 156 + canvasWrap.appendChild(canvas); 157 + } catch (error) { 158 + canvasWrap.appendChild(buildFallback(`Unable to render screen: ${error.message}`)); 159 + } 160 + } else if (usablePoints.length > 0) { 161 + canvasWrap.appendChild(buildFallback('Screenshot capture failed for this screen.')); 162 + } else if (screen.gazePoints.length > 0) { 163 + canvasWrap.appendChild(buildFallback('No renderable heatmap data for this screen.')); 164 + } else { 165 + canvasWrap.appendChild(buildFallback('No gaze points were recorded for this screen.')); 166 + } 167 + 168 + card.appendChild(header); 169 + card.appendChild(canvasWrap); 170 + this.galleryElement.appendChild(card); 171 + } 172 + } 173 + }
+427
js/gazeTracker.js
··· 1 + /** 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 18 + */ 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 + 58 + export class GazeTracker { 59 + constructor() { 60 + this.isInitialized = false; 61 + this.inputMode = 'webgazer'; 62 + this.mode = 'idle'; 63 + this.stats = createEmptyStats(); 64 + this.gazePoints = []; 65 + this.rawSamples = []; 66 + this.screenRecords = new Map(); 67 + this.currentScreenKey = null; 68 + this.currentMetrics = null; 69 + this.onGazeData = null; 70 + this.fixationThreshold = 50; 71 + this.fixationMinDuration = 150; 72 + this.currentFixation = null; 73 + this.lastAcceptedAt = 0; 74 + this.logInterval = 50; 75 + } 76 + 77 + setInputMode(mode) { 78 + this.inputMode = mode; 79 + } 80 + 81 + getInputMode() { 82 + return this.inputMode; 83 + } 84 + 85 + async initialize() { 86 + if (this.isInitialized) { 87 + return true; 88 + } 89 + if (typeof webgazer === 'undefined') { 90 + console.error('WebGazer is not available.'); 91 + return false; 92 + } 93 + 94 + try { 95 + window.saveDataAcrossSessions = false; 96 + webgazer.params.faceMeshSolutionPath = 'https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh'; 97 + webgazer 98 + .setRegression('ridge') 99 + .setGazeListener((data, elapsedTime) => { 100 + if (!data) { 101 + return; 102 + } 103 + this.handleGaze(data, elapsedTime); 104 + }); 105 + 106 + await webgazer.begin(); 107 + webgazer.showVideoPreview(false); 108 + webgazer.showPredictionPoints(false); 109 + webgazer.resume(); 110 + this.isInitialized = true; 111 + return true; 112 + } catch (error) { 113 + console.error('Failed to initialize WebGazer:', error); 114 + return false; 115 + } 116 + } 117 + 118 + setMode(mode) { 119 + this.mode = mode; 120 + if (mode === 'idle' && typeof webgazer !== 'undefined') { 121 + webgazer.pause(); 122 + } else if (this.isInitialized && typeof webgazer !== 'undefined') { 123 + webgazer.resume(); 124 + } 125 + } 126 + 127 + updateMetrics(metrics) { 128 + this.currentMetrics = metrics ? { ...metrics } : null; 129 + if (metrics) { 130 + this.currentScreenKey = metrics.key; 131 + this.ensureScreenRecord(metrics); 132 + } 133 + } 134 + 135 + ensureScreenRecord(metrics) { 136 + if (!metrics) { 137 + return null; 138 + } 139 + 140 + if (!this.screenRecords.has(metrics.key)) { 141 + this.screenRecords.set(metrics.key, createScreenRecord(metrics)); 142 + } 143 + 144 + const record = this.screenRecords.get(metrics.key); 145 + record.title = metrics.title || record.title; 146 + record.lastSeenAt = metrics.timestamp; 147 + record.viewport.width = Math.max(record.viewport.width, metrics.viewportWidth); 148 + record.viewport.height = Math.max(record.viewport.height, metrics.viewportHeight); 149 + record.document.width = Math.max(record.document.width, metrics.documentWidth); 150 + record.document.height = Math.max(record.document.height, metrics.documentHeight); 151 + return record; 152 + } 153 + 154 + setScreenScreenshot(screenKey, screenshot) { 155 + const record = this.screenRecords.get(screenKey); 156 + if (!record) { 157 + return; 158 + } 159 + 160 + record.screenshot = { 161 + dataUrl: screenshot?.dataUrl || null, 162 + width: screenshot?.width || 0, 163 + height: screenshot?.height || 0, 164 + capturedAt: screenshot?.capturedAt || null, 165 + status: screenshot?.dataUrl ? 'ready' : 'failed' 166 + }; 167 + } 168 + 169 + markScreenshotFailed(screenKey) { 170 + const record = this.screenRecords.get(screenKey); 171 + if (!record) { 172 + return; 173 + } 174 + 175 + record.screenshot.status = 'failed'; 176 + record.screenshot.dataUrl = null; 177 + } 178 + 179 + recordInteractionEvent(event) { 180 + const record = this.screenRecords.get(event.screenKey); 181 + if (record) { 182 + record.interactionEvents.push(event); 183 + } 184 + } 185 + 186 + handleGaze(data) { 187 + const now = Date.now(); 188 + const sample = { 189 + timestamp: now, 190 + viewportX: Math.round(data.x), 191 + viewportY: Math.round(data.y), 192 + inIframe: false 193 + }; 194 + 195 + this.rawSamples.push(sample); 196 + if (this.rawSamples.length > 400) { 197 + this.rawSamples.shift(); 198 + } 199 + 200 + if (!this.currentMetrics || this.mode === 'idle') { 201 + return; 202 + } 203 + 204 + this.stats.lastGazeX = sample.viewportX; 205 + this.stats.lastGazeY = sample.viewportY; 206 + 207 + const metrics = this.currentMetrics; 208 + const iframeX = sample.viewportX - metrics.iframeLeft; 209 + const iframeY = sample.viewportY - metrics.iframeTop; 210 + const inIframe = iframeX >= 0 && 211 + iframeX <= metrics.iframeWidth && 212 + iframeY >= 0 && 213 + iframeY <= metrics.iframeHeight; 214 + 215 + const fullSample = { 216 + ...sample, 217 + iframeX: Math.round(iframeX), 218 + iframeY: Math.round(iframeY), 219 + inIframe, 220 + screenKey: metrics.key, 221 + scrollX: metrics.scrollX, 222 + scrollY: metrics.scrollY, 223 + docX: Math.round(iframeX + metrics.scrollX), 224 + docY: Math.round(iframeY + metrics.scrollY), 225 + viewportWidth: metrics.viewportWidth, 226 + viewportHeight: metrics.viewportHeight, 227 + documentWidth: metrics.documentWidth, 228 + documentHeight: metrics.documentHeight 229 + }; 230 + 231 + this.rawSamples[this.rawSamples.length - 1] = fullSample; 232 + 233 + if (this.mode === 'recording' && now - this.lastAcceptedAt >= this.logInterval) { 234 + this.lastAcceptedAt = now; 235 + this.acceptGazePoint(fullSample); 236 + } 237 + } 238 + 239 + ingestSyntheticGaze(sample) { 240 + if (!this.currentMetrics || this.mode !== 'recording') { 241 + return; 242 + } 243 + 244 + const now = sample.timestamp || Date.now(); 245 + if (now - this.lastAcceptedAt < this.logInterval) { 246 + return; 247 + } 248 + this.lastAcceptedAt = now; 249 + this.stats.lastGazeX = sample.viewportX; 250 + this.stats.lastGazeY = sample.viewportY; 251 + this.acceptGazePoint({ 252 + timestamp: now, 253 + ...sample 254 + }); 255 + } 256 + 257 + acceptGazePoint(point) { 258 + this.stats.gazePoints += 1; 259 + this.gazePoints.push(point); 260 + const record = this.ensureScreenRecord({ 261 + key: point.screenKey, 262 + title: this.screenRecords.get(point.screenKey)?.title || point.screenKey, 263 + timestamp: point.timestamp, 264 + viewportWidth: point.viewportWidth, 265 + viewportHeight: point.viewportHeight, 266 + documentWidth: point.documentWidth, 267 + documentHeight: point.documentHeight 268 + }); 269 + 270 + if (record && point.inIframe) { 271 + record.gazePoints.push(point); 272 + } 273 + 274 + this.detectFixation(point); 275 + 276 + if (this.onGazeData) { 277 + this.onGazeData(point); 278 + } 279 + } 280 + 281 + detectFixation(point) { 282 + const now = point.timestamp; 283 + if (!this.currentFixation) { 284 + this.currentFixation = { 285 + x: point.docX, 286 + y: point.docY, 287 + screenKey: point.screenKey, 288 + startTime: now, 289 + pointCount: 1 290 + }; 291 + return; 292 + } 293 + 294 + const dx = point.docX - this.currentFixation.x; 295 + const dy = point.docY - this.currentFixation.y; 296 + const distance = Math.hypot(dx, dy); 297 + const sameScreen = point.screenKey === this.currentFixation.screenKey; 298 + 299 + if (sameScreen && distance <= this.fixationThreshold) { 300 + this.currentFixation.pointCount += 1; 301 + this.currentFixation.x = 302 + (this.currentFixation.x * (this.currentFixation.pointCount - 1) + point.docX) / 303 + this.currentFixation.pointCount; 304 + this.currentFixation.y = 305 + (this.currentFixation.y * (this.currentFixation.pointCount - 1) + point.docY) / 306 + this.currentFixation.pointCount; 307 + return; 308 + } 309 + 310 + this.finalizeFixation(now); 311 + this.currentFixation = { 312 + x: point.docX, 313 + y: point.docY, 314 + screenKey: point.screenKey, 315 + startTime: now, 316 + pointCount: 1 317 + }; 318 + } 319 + 320 + finalizeFixation(endTime = Date.now()) { 321 + if (!this.currentFixation) { 322 + return; 323 + } 324 + 325 + const duration = endTime - this.currentFixation.startTime; 326 + if (duration >= this.fixationMinDuration) { 327 + this.stats.fixations += 1; 328 + this.stats.totalFixationDuration += duration; 329 + this.stats.avgFixationDuration = Math.round(this.stats.totalFixationDuration / this.stats.fixations); 330 + const record = this.screenRecords.get(this.currentFixation.screenKey); 331 + if (record) { 332 + record.fixationCount += 1; 333 + } 334 + } 335 + 336 + this.currentFixation = null; 337 + } 338 + 339 + getCalibrationSamples(fromTimestamp) { 340 + return this.rawSamples.filter((sample) => sample.timestamp >= fromTimestamp); 341 + } 342 + 343 + getStats() { 344 + return { ...this.stats }; 345 + } 346 + 347 + getGazeData() { 348 + return [...this.gazePoints]; 349 + } 350 + 351 + getScreenRecords() { 352 + return Array.from(this.screenRecords.values()).map((screen) => ({ 353 + ...screen, 354 + viewport: { ...screen.viewport }, 355 + document: { ...screen.document }, 356 + screenshot: { ...screen.screenshot }, 357 + gazePoints: [...screen.gazePoints], 358 + interactionEvents: [...screen.interactionEvents] 359 + })); 360 + } 361 + 362 + exportData() { 363 + const screens = this.getScreenRecords(); 364 + const screenLookup = {}; 365 + screens.forEach((screen) => { 366 + screenLookup[screen.key] = { 367 + key: screen.key, 368 + title: screen.title, 369 + firstSeenAt: screen.firstSeenAt, 370 + lastSeenAt: screen.lastSeenAt, 371 + documentWidth: screen.document.width, 372 + documentHeight: screen.document.height, 373 + viewportWidth: screen.viewport.width, 374 + viewportHeight: screen.viewport.height, 375 + screenshotCaptured: screen.screenshot.status === 'ready', 376 + screenshotWidth: screen.screenshot.width, 377 + screenshotHeight: screen.screenshot.height, 378 + gazePointCount: screen.gazePoints.length, 379 + fixationCount: screen.fixationCount 380 + }; 381 + }); 382 + 383 + return { 384 + stats: this.getStats(), 385 + points: this.getGazeData(), 386 + screens: screenLookup, 387 + inputMode: this.getInputMode() 388 + }; 389 + } 390 + 391 + reset() { 392 + this.inputMode = 'webgazer'; 393 + this.mode = 'idle'; 394 + this.stats = createEmptyStats(); 395 + this.gazePoints = []; 396 + this.rawSamples = []; 397 + this.screenRecords = new Map(); 398 + this.currentScreenKey = null; 399 + this.currentMetrics = null; 400 + this.currentFixation = null; 401 + this.lastAcceptedAt = 0; 402 + } 403 + 404 + async end() { 405 + this.finalizeFixation(); 406 + this.setMode('idle'); 407 + if (typeof webgazer !== 'undefined') { 408 + try { 409 + webgazer.pause(); 410 + const videoElement = webgazer.getVideoElement ? webgazer.getVideoElement() : null; 411 + const stream = videoElement?.srcObject || webgazer.params?.videoStream || null; 412 + if (stream?.getTracks) { 413 + stream.getTracks().forEach((track) => track.stop()); 414 + } 415 + webgazer.end(); 416 + } catch (error) { 417 + console.warn('[GazeTracker] Failed to end WebGazer cleanly:', error); 418 + } 419 + } 420 + document.querySelectorAll('#webgazerVideoContainer, video').forEach((element) => { 421 + if (element.id === 'webgazerVideoContainer' || element.dataset?.webgazerVideoFeed) { 422 + element.remove(); 423 + } 424 + }); 425 + this.isInitialized = false; 426 + } 427 + }
+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 + }
+597 -204
js/main.js
··· 1 - /** 2 - * UXET Main - Application entry point 3 - */ 4 - 1 + import { Session } from './session.js'; 5 2 import { Tracker } from './tracker.js'; 6 - import { Session } from './session.js'; 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'; 7 8 8 9 class UXETApp { 9 10 constructor() { 10 - this.tracker = new Tracker(); 11 + this.themeStorageKey = 'uxet-theme'; 12 + this.theme = this.getStoredTheme(); 11 13 this.session = new Session(); 12 - this.currentTask = ''; 14 + this.tracker = new Tracker(); 15 + this.gazeTracker = new GazeTracker(); 16 + this.bridge = new IframeBridge(); 17 + this.winConditions = new WinConditionRegistry(); 13 18 this.selectedApp = null; 19 + this.captureJobs = new Map(); 20 + this.calibrationPassed = false; 21 + this.calibrationSkippedByDebug = false; 22 + this.gazeInitialized = false; 23 + this.lastCalibrationResult = null; 24 + this.debugState = { 25 + mouseGazeMode: false, 26 + setupPanelOpen: false, 27 + sessionDrawerOpen: false 28 + }; 14 29 15 30 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 31 + setupShell: document.getElementById('setup-shell'), 32 + debriefShell: document.getElementById('debrief-shell'), 33 + sessionStage: document.getElementById('session-stage'), 34 + 35 + appSelect: document.getElementById('app-select'), 24 36 loadAppBtn: document.getElementById('load-app-btn'), 25 37 resetBtn: document.getElementById('reset-btn'), 26 38 exportBtn: document.getElementById('export-btn'), 27 - newTestBtn: document.getElementById('new-test-btn'), 28 - beginTestBtn: document.getElementById('begin-test-btn'), 29 - // Iframe 30 - 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'), 36 - debriefScreen: document.getElementById('debrief-screen'), 37 - taskDescription: document.getElementById('task-description'), 38 - // Timer 39 + startTestBtn: document.getElementById('start-test-btn'), 40 + startCurtain: document.getElementById('start-curtain'), 41 + themeDarkBtn: document.getElementById('theme-dark-btn'), 42 + themeLightBtn: document.getElementById('theme-light-btn'), 43 + 44 + debugToggleBtn: document.getElementById('debug-toggle-btn'), 45 + debugPanel: document.getElementById('debug-panel'), 46 + debugSkipBtn: document.getElementById('debug-skip-calibration'), 47 + debugMouseGazeToggle: document.getElementById('debug-mouse-gaze-toggle'), 48 + debugExitBtn: document.getElementById('debug-exit-test'), 49 + 50 + sessionDebugToggleBtn: document.getElementById('session-debug-toggle-btn'), 51 + sessionDebugDrawer: document.getElementById('session-debug-drawer'), 52 + sessionDebugSkipBtn: document.getElementById('session-debug-skip-calibration'), 53 + sessionDebugMouseGazeToggle: document.getElementById('session-debug-mouse-gaze-toggle'), 54 + sessionDebugExitBtn: document.getElementById('session-debug-exit-test'), 55 + 56 + sessionStatus: document.getElementById('session-status'), 39 57 sessionTimer: document.getElementById('session-timer'), 40 - // Sidebar (for internal tracking, hidden during test) 41 - sidebar: document.getElementById('sidebar'), 42 58 currentTaskText: document.getElementById('current-task-text'), 43 - sessionStatus: document.getElementById('session-status'), 44 - // Debrief stats 59 + sessionMessage: document.getElementById('session-message'), 60 + 61 + iframe: document.getElementById('test-iframe'), 62 + 63 + calibrationScreen: document.getElementById('calibration-screen'), 64 + calibrationStage: document.getElementById('calibration-stage'), 65 + calibrationTarget: document.getElementById('calibration-target'), 66 + calibrationInstruction: document.getElementById('calibration-instruction'), 67 + calibrationProgress: document.getElementById('calibration-progress'), 68 + calibrationClicks: document.getElementById('calibration-clicks'), 69 + calibrationQuality: document.getElementById('calibration-quality'), 70 + calibrationFeedback: document.getElementById('calibration-feedback'), 71 + 72 + startOverlay: document.getElementById('start-overlay'), 73 + startOverlayTask: document.getElementById('start-overlay-task'), 74 + calibrationFailureOverlay: document.getElementById('calibration-failure-overlay'), 75 + calibrationFailureSummary: document.getElementById('calibration-failure-summary'), 76 + retryCalibrationBtn: document.getElementById('retry-calibration-btn'), 77 + 78 + debriefScreen: document.getElementById('debrief-screen'), 45 79 debriefTime: document.getElementById('debrief-time'), 46 80 debriefClicks: document.getElementById('debrief-clicks'), 47 81 debriefKeys: document.getElementById('debrief-keys'), 48 82 debriefDistance: document.getElementById('debrief-distance'), 49 83 debriefScrolls: document.getElementById('debrief-scrolls'), 50 84 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') 85 + debriefGazePoints: document.getElementById('debrief-gaze-points'), 86 + debriefFixations: document.getElementById('debrief-fixations'), 87 + debriefFixationDuration: document.getElementById('debrief-fixation-duration'), 88 + galleryLabel: document.getElementById('screen-gallery-label'), 89 + gallery: document.getElementById('screen-gallery') 64 90 }; 65 91 92 + this.debriefRenderer = new DebriefRenderer({ 93 + galleryElement: this.elements.gallery, 94 + labelElement: this.elements.galleryLabel 95 + }); 96 + this.calibration = new CalibrationController({ 97 + gazeTracker: this.gazeTracker, 98 + elements: { 99 + calibrationStage: this.elements.calibrationStage, 100 + calibrationTarget: this.elements.calibrationTarget, 101 + calibrationInstruction: this.elements.calibrationInstruction, 102 + calibrationProgress: this.elements.calibrationProgress, 103 + calibrationClicks: this.elements.calibrationClicks, 104 + calibrationQuality: this.elements.calibrationQuality, 105 + calibrationFeedback: this.elements.calibrationFeedback 106 + }, 107 + getViewportRect: () => ({ 108 + left: 0, 109 + top: 0, 110 + width: window.innerWidth, 111 + height: window.innerHeight 112 + }), 113 + getSafeAreaInsets: () => { 114 + const hudHeight = this.elements.calibrationScreen.classList.contains('hidden') 115 + ? 0 116 + : Math.ceil(this.elements.calibrationScreen.querySelector('#calibration-hud')?.getBoundingClientRect().height || 0); 117 + return { 118 + left: Math.max(48, Math.round(window.innerWidth * 0.06)), 119 + right: Math.max(48, Math.round(window.innerWidth * 0.06)), 120 + top: Math.max(72, Math.round(window.innerHeight * 0.1), hudHeight + 20), 121 + bottom: Math.max(48, Math.round(window.innerHeight * 0.06)) 122 + }; 123 + } 124 + }); 125 + 66 126 this.init(); 67 127 } 68 128 69 129 init() { 130 + this.applyTheme(this.theme); 70 131 this.bindEvents(); 71 - this.setupCallbacks(); 72 - this.setupCompletionAPI(); 132 + this.selectApp(this.elements.appSelect); 133 + this.bindCallbacks(); 134 + this.syncDebugUi(); 135 + this.updateUiForState('idle'); 73 136 } 74 137 75 138 bindEvents() { 76 - // Custom dropdown 77 - this.elements.dropdownTrigger.addEventListener('click', (e) => { 78 - e.stopPropagation(); 79 - this.toggleDropdown(); 139 + this.elements.appSelect.addEventListener('change', (event) => this.selectApp(event.target)); 140 + this.elements.loadAppBtn.addEventListener('click', () => this.loadSelectedApp()); 141 + this.elements.resetBtn.addEventListener('click', () => this.resetSession()); 142 + this.elements.exportBtn.addEventListener('click', () => this.exportData()); 143 + this.elements.startTestBtn.addEventListener('click', () => this.beginTesting()); 144 + this.elements.retryCalibrationBtn.addEventListener('click', () => this.retryCalibration()); 145 + this.elements.themeDarkBtn.addEventListener('click', () => this.setTheme('dark')); 146 + this.elements.themeLightBtn.addEventListener('click', () => this.setTheme('light')); 147 + 148 + this.elements.debugToggleBtn.addEventListener('click', () => { 149 + this.debugState.setupPanelOpen = !this.debugState.setupPanelOpen; 150 + this.syncDebugUi(); 80 151 }); 152 + this.elements.debugSkipBtn.addEventListener('click', () => this.debugSkipCalibration()); 153 + this.elements.debugMouseGazeToggle.addEventListener('change', (event) => { 154 + this.toggleMouseGazeMode(event.target.checked); 155 + }); 156 + this.elements.debugExitBtn.addEventListener('click', () => this.debugExitTest()); 81 157 82 - document.querySelectorAll('.dropdown-item').forEach(item => { 83 - item.addEventListener('click', () => this.selectApp(item)); 158 + this.elements.sessionDebugToggleBtn.addEventListener('click', () => { 159 + this.debugState.sessionDrawerOpen = !this.debugState.sessionDrawerOpen; 160 + this.syncDebugUi(); 161 + }); 162 + this.elements.sessionDebugSkipBtn.addEventListener('click', () => this.debugSkipCalibration()); 163 + this.elements.sessionDebugMouseGazeToggle.addEventListener('change', (event) => { 164 + this.toggleMouseGazeMode(event.target.checked); 165 + }); 166 + this.elements.sessionDebugExitBtn.addEventListener('click', () => this.debugExitTest()); 167 + 168 + this.elements.iframe.addEventListener('load', () => this.onIframeLoad()); 169 + this.elements.calibrationTarget.addEventListener('click', () => this.calibration.handleTargetClick()); 170 + 171 + window.addEventListener('resize', () => { 172 + this.calibration.updateUi(); 173 + this.refreshMetricsIfActive(); 84 174 }); 85 175 86 - // Close dropdown when clicking outside 87 - document.addEventListener('click', () => this.closeDropdown()); 176 + window.addEventListener('keydown', (event) => { 177 + if (event.shiftKey && event.key === 'Escape' && this.session.state === 'recording') { 178 + event.preventDefault(); 179 + this.debugExitTest(); 180 + } 181 + }); 182 + } 88 183 89 - // Buttons 90 - this.elements.loadAppBtn.addEventListener('click', () => this.loadApp()); 91 - this.elements.beginTestBtn.addEventListener('click', () => this.beginTesting()); 92 - this.elements.resetBtn.addEventListener('click', () => this.resetSession()); 93 - this.elements.exportBtn.addEventListener('click', () => this.exportData()); 94 - this.elements.newTestBtn.addEventListener('click', () => this.resetSession()); 184 + bindCallbacks() { 185 + this.session.onStateChange = (state, details) => this.updateUiForState(state, details); 186 + this.session.onTimerUpdate = (formatted) => { 187 + this.elements.sessionTimer.textContent = formatted; 188 + }; 95 189 96 - this.elements.iframe.addEventListener('load', () => this.onIframeLoad()); 190 + this.tracker.onEvent = (event) => { 191 + this.gazeTracker.recordInteractionEvent(event); 192 + }; 193 + this.tracker.onMousePosition = (payload) => { 194 + if (!this.debugState.mouseGazeMode || this.session.state !== 'recording') { 195 + return; 196 + } 197 + this.gazeTracker.ingestSyntheticGaze({ 198 + timestamp: payload.timestamp, 199 + viewportX: Math.round(payload.metrics.iframeLeft + payload.clientX), 200 + viewportY: Math.round(payload.metrics.iframeTop + payload.clientY), 201 + iframeX: payload.clientX, 202 + iframeY: payload.clientY, 203 + inIframe: true, 204 + screenKey: payload.metrics.key, 205 + scrollX: payload.metrics.scrollX, 206 + scrollY: payload.metrics.scrollY, 207 + docX: payload.docX, 208 + docY: payload.docY, 209 + viewportWidth: payload.metrics.viewportWidth, 210 + viewportHeight: payload.metrics.viewportHeight, 211 + documentWidth: payload.metrics.documentWidth, 212 + documentHeight: payload.metrics.documentHeight 213 + }); 214 + }; 215 + 216 + this.bridge.onScreenChange = (metrics) => { 217 + this.gazeTracker.updateMetrics(metrics); 218 + }; 219 + this.bridge.onScreenStable = (metrics) => { 220 + this.gazeTracker.updateMetrics(metrics); 221 + this.captureScreen(metrics.key); 222 + }; 223 + this.bridge.onError = (error) => { 224 + this.failSession(error.message || 'Failed to attach to iframe.'); 225 + }; 226 + 227 + this.calibration.onComplete = (result) => { 228 + this.lastCalibrationResult = result; 229 + if (result.passed) { 230 + this.calibrationPassed = true; 231 + this.calibrationSkippedByDebug = false; 232 + this.session.setState('ready_to_start'); 233 + } else { 234 + this.calibrationPassed = false; 235 + this.session.setState('calibration_failed'); 236 + } 237 + }; 97 238 } 98 239 99 - setupCallbacks() { 100 - this.tracker.onStatsUpdate = (stats) => this.updateStats(stats); 101 - this.tracker.onEvent = (event) => this.addEventToLog(event); 240 + getSelectedAppFromElement(selectElement) { 241 + const option = selectElement?.options?.[selectElement.selectedIndex]; 242 + if (!option?.value) { 243 + return null; 244 + } 102 245 103 - this.session.onStatusChange = (status) => this.updateSessionStatus(status); 104 - this.session.onTimerUpdate = (time) => { 105 - this.elements.sessionTimer.textContent = time; 246 + return { 247 + value: option.value, 248 + task: option.dataset.task, 249 + name: option.textContent.trim(), 250 + win: option.dataset.win || '' 106 251 }; 107 252 } 108 253 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 || {}); 114 - } 115 - }); 254 + syncSelectedAppUi() { 255 + this.elements.currentTaskText.textContent = this.selectedApp?.task || 'None'; 256 + this.elements.startOverlayTask.textContent = this.selectedApp?.task || 'Task will appear here.'; 116 257 } 117 258 118 - // Custom dropdown methods 119 - toggleDropdown() { 120 - this.elements.dropdown.classList.toggle('open'); 259 + selectApp(selectElement) { 260 + this.selectedApp = this.getSelectedAppFromElement(selectElement); 261 + this.syncSelectedAppUi(); 121 262 } 122 263 123 - closeDropdown() { 124 - this.elements.dropdown.classList.remove('open'); 264 + toggleMouseGazeMode(enabled) { 265 + if (this.session.state === 'recording') { 266 + this.syncDebugUi(); 267 + return; 268 + } 269 + 270 + this.debugState.mouseGazeMode = enabled; 271 + this.gazeTracker.setInputMode(enabled ? 'mouse' : 'webgazer'); 272 + this.syncDebugUi(); 273 + 274 + if (enabled) { 275 + this.calibrationSkippedByDebug = true; 276 + this.calibrationPassed = false; 277 + this.lastCalibrationResult = null; 278 + if (this.gazeInitialized) { 279 + this.gazeTracker.end().catch(() => { }); 280 + this.gazeInitialized = false; 281 + } 282 + if (['calibrating', 'calibration_failed'].includes(this.session.state)) { 283 + this.session.setState('ready_to_start'); 284 + } 285 + } else if (this.session.state === 'ready_to_start' && this.calibrationSkippedByDebug) { 286 + this.calibrationSkippedByDebug = false; 287 + this.calibrationPassed = false; 288 + this.session.setState('calibrating'); 289 + this.gazeTracker.setMode('calibration'); 290 + this.calibration.start(); 291 + } 125 292 } 126 293 127 - selectApp(item) { 128 - // Update selection UI 129 - document.querySelectorAll('.dropdown-item').forEach(i => i.classList.remove('selected')); 130 - item.classList.add('selected'); 294 + syncDebugUi() { 295 + const recording = this.session.state === 'recording'; 296 + const canSkipCalibration = this.session.state === 'calibrating'; 297 + const sessionDrawerStates = new Set(['calibrating', 'calibration_failed', 'ready_to_start', 'recording']); 298 + const drawerVisible = sessionDrawerStates.has(this.session.state); 299 + 300 + this.elements.debugPanel.classList.toggle('hidden', !this.debugState.setupPanelOpen); 301 + this.elements.debugToggleBtn.setAttribute('aria-expanded', String(this.debugState.setupPanelOpen)); 302 + this.elements.sessionDebugToggleBtn.classList.toggle('hidden', !drawerVisible); 303 + this.elements.sessionDebugDrawer.classList.toggle('hidden', !drawerVisible || !this.debugState.sessionDrawerOpen); 131 304 132 - // Store selection 133 - this.selectedApp = { 134 - value: item.dataset.value, 135 - task: item.dataset.task, 136 - name: item.querySelector('.item-name').textContent 137 - }; 305 + this.elements.debugMouseGazeToggle.checked = this.debugState.mouseGazeMode; 306 + this.elements.sessionDebugMouseGazeToggle.checked = this.debugState.mouseGazeMode; 138 307 139 - this.elements.dropdownValue.textContent = this.selectedApp.name; 140 - this.closeDropdown(); 308 + this.elements.debugMouseGazeToggle.disabled = recording; 309 + this.elements.sessionDebugMouseGazeToggle.disabled = recording; 310 + 311 + this.elements.debugSkipBtn.disabled = !canSkipCalibration; 312 + this.elements.sessionDebugSkipBtn.disabled = !canSkipCalibration; 313 + 314 + this.elements.debugExitBtn.disabled = !recording; 315 + this.elements.sessionDebugExitBtn.disabled = !recording; 141 316 } 142 317 143 - loadApp() { 318 + async loadSelectedApp() { 144 319 if (!this.selectedApp) { 145 - alert('Please select an app to test'); 320 + window.alert('Select an app to test.'); 146 321 return; 147 322 } 148 323 149 - this.currentTask = this.selectedApp.task; 324 + await this.resetRuntimeOnly(); 325 + this.session.reset(); 326 + this.calibrationPassed = false; 327 + this.calibrationSkippedByDebug = false; 328 + this.lastCalibrationResult = null; 329 + this.session.setAppName(this.selectedApp.name); 330 + this.session.setTask(this.selectedApp.task); 331 + this.elements.currentTaskText.textContent = this.selectedApp.task; 332 + this.elements.startOverlayTask.textContent = this.selectedApp.task; 333 + this.elements.iframe.classList.remove('hidden'); 334 + this.elements.iframe.src = this.withThemeQuery(this.selectedApp.value); 335 + this.session.setState('loading_app'); 336 + } 150 337 151 - // Update task displays 152 - this.elements.taskDescription.textContent = this.currentTask; 153 - this.elements.currentTaskText.textContent = this.currentTask; 338 + async onIframeLoad() { 339 + if (!this.selectedApp || !this.elements.iframe.src || this.elements.iframe.src.includes('about:blank')) { 340 + return; 341 + } 342 + 343 + const attached = this.bridge.attach(this.elements.iframe); 344 + if (!attached) { 345 + return; 346 + } 347 + 348 + await this.forceMetricsRefresh(); 349 + const metrics = this.bridge.getMetricsSnapshot(); 350 + this.gazeTracker.updateMetrics(metrics); 351 + this.captureScreen(metrics?.key); 352 + 353 + if (this.debugState.mouseGazeMode) { 354 + this.gazeTracker.setInputMode('mouse'); 355 + this.gazeTracker.setMode('recording'); 356 + this.calibrationPassed = false; 357 + this.calibrationSkippedByDebug = true; 358 + this.lastCalibrationResult = null; 359 + this.session.setState('ready_to_start'); 360 + return; 361 + } 362 + 363 + const initialized = await this.gazeTracker.initialize(); 364 + if (!initialized) { 365 + this.failSession('WebGazer failed to initialize.'); 366 + return; 367 + } 154 368 155 - // Load the iframe but keep it hidden 156 - this.elements.iframe.src = this.selectedApp.value; 157 - this.elements.iframeStatus.textContent = 'Loading app...'; 158 - this.session.setAppName(this.selectedApp.value); 159 - this.session.setTask(this.currentTask); 369 + this.gazeInitialized = true; 370 + this.gazeTracker.setInputMode('webgazer'); 371 + this.session.setState('calibrating'); 372 + this.gazeTracker.setMode('calibration'); 373 + this.lastCalibrationResult = null; 374 + this.calibration.start(); 160 375 } 161 376 162 - onIframeLoad() { 163 - const src = this.elements.iframe.src; 164 - if (!src || src === 'about:blank') return; 377 + debugSkipCalibration() { 378 + if (this.session.state !== 'calibrating') { 379 + return; 380 + } 165 381 166 - // Hide placeholder, show task briefing 167 - this.elements.iframePlaceholder.classList.add('hidden'); 168 - this.elements.taskBriefing.classList.remove('hidden'); 169 - this.elements.iframeStatus.textContent = 'Ready to test'; 382 + this.calibrationSkippedByDebug = true; 383 + this.calibrationPassed = false; 384 + this.elements.calibrationFeedback.textContent = 'Calibration bypassed via debug override'; 385 + this.elements.calibrationQuality.textContent = 'Debug override'; 386 + this.lastCalibrationResult = null; 387 + this.session.setState('ready_to_start'); 388 + } 389 + 390 + retryCalibration() { 391 + if (this.session.state !== 'calibration_failed') { 392 + return; 393 + } 170 394 171 - // Hide the iframe until testing begins 172 - this.elements.iframe.style.visibility = 'hidden'; 395 + this.lastCalibrationResult = null; 396 + this.calibrationPassed = false; 397 + this.calibrationSkippedByDebug = false; 398 + this.elements.calibrationFailureSummary.textContent = 'Average error summary will appear here.'; 399 + this.gazeTracker.setMode('calibration'); 400 + this.session.setState('calibrating'); 401 + this.calibration.start(); 173 402 } 174 403 175 404 beginTesting() { 176 - // Hide task briefing, show iframe 177 - this.elements.taskBriefing.classList.add('hidden'); 178 - this.elements.iframe.style.visibility = 'visible'; 405 + if (!this.selectedApp) { 406 + return; 407 + } 408 + if (!(this.calibrationPassed || this.calibrationSkippedByDebug)) { 409 + this.elements.sessionMessage.textContent = 'Calibration must pass before recording can start.'; 410 + return; 411 + } 179 412 180 - // Enter testing mode - hide header and sidebar 181 - this.elements.appContainer.classList.add('testing-mode'); 182 - 183 - // Attach tracker and start session 184 - const attached = this.tracker.attachToIframe(this.elements.iframe); 413 + const attached = this.tracker.attach(this.bridge); 185 414 if (!attached) { 186 - console.warn('Could not attach tracker to iframe'); 415 + this.failSession('Failed to attach tracker to iframe.'); 416 + return; 187 417 } 188 418 189 - this.session.start(); 419 + this.session.startRecording(); 420 + this.gazeTracker.setMode('recording'); 190 421 this.tracker.start(); 422 + this.winConditions.start(this.selectedApp.win, { 423 + bridge: this.bridge, 424 + session: this.session, 425 + complete: (details) => this.finishTest(details) 426 + }); 191 427 } 192 428 193 - onTaskComplete(details) { 194 - if (this.session.status !== 'recording') return; 429 + debugExitTest() { 430 + if (this.session.state !== 'recording') { 431 + return; 432 + } 433 + this.finishTest({ strategy: 'debug-manual' }); 434 + } 435 + 436 + async finishTest(details) { 437 + if (this.session.state !== 'recording') { 438 + return; 439 + } 195 440 196 - // Stop tracking 197 - this.session.stop(); 441 + this.session.setState('finishing'); 442 + this.winConditions.stop(); 198 443 this.tracker.stop(); 444 + this.gazeTracker.finalizeFixation(); 199 445 200 - // Log completion 201 - this.tracker.logEvent('system', 'Task completed automatically'); 446 + await this.forceMetricsRefresh(); 447 + const currentMetrics = this.bridge.getMetricsSnapshot(); 448 + if (currentMetrics) { 449 + this.gazeTracker.updateMetrics(currentMetrics); 450 + await this.captureScreen(currentMetrics.key); 451 + } 452 + 453 + if (this.gazeInitialized) { 454 + await this.gazeTracker.end(); 455 + this.gazeInitialized = false; 456 + } 457 + 458 + this.session.stopRecording('finishing'); 459 + await this.renderDebrief(details); 460 + this.session.setState('complete'); 461 + } 202 462 203 - // Exit testing mode 204 - this.elements.appContainer.classList.remove('testing-mode'); 463 + async captureScreen(screenKey) { 464 + if (!this.bridge.isAttached || !screenKey) { 465 + return null; 466 + } 467 + if (this.captureJobs.has(screenKey)) { 468 + return this.captureJobs.get(screenKey); 469 + } 205 470 206 - // Populate debrief stats 471 + const job = (async () => { 472 + try { 473 + const screenshot = await this.bridge.captureScreenshot(); 474 + this.gazeTracker.setScreenScreenshot(screenKey, screenshot); 475 + return screenshot; 476 + } catch (error) { 477 + console.warn('[UXET] Screenshot capture failed:', error); 478 + this.gazeTracker.markScreenshotFailed(screenKey); 479 + return null; 480 + } finally { 481 + this.captureJobs.delete(screenKey); 482 + } 483 + })(); 484 + 485 + this.captureJobs.set(screenKey, job); 486 + return job; 487 + } 488 + 489 + async renderDebrief(details) { 207 490 const stats = this.tracker.getStats(); 208 - const timeFormatted = this.formatTime(this.session.elapsed); 491 + const gazeStats = this.gazeTracker.getStats(); 492 + const screens = this.gazeTracker.getScreenRecords(); 493 + const inputMode = this.gazeTracker.getInputMode(); 209 494 210 - this.elements.debriefTime.textContent = timeFormatted; 495 + this.elements.debriefTime.textContent = this.session.formatTime(this.session.elapsed); 211 496 this.elements.debriefClicks.textContent = stats.mouse.clicks.toLocaleString(); 212 497 this.elements.debriefKeys.textContent = stats.keyboard.totalKeys.toLocaleString(); 213 498 this.elements.debriefDistance.textContent = Math.round(stats.mouse.distance).toLocaleString(); 214 499 this.elements.debriefScrolls.textContent = stats.mouse.scrollEvents.toLocaleString(); 215 500 this.elements.debriefVelocity.textContent = stats.mouse.avgVelocity.toLocaleString(); 501 + this.elements.debriefGazePoints.textContent = gazeStats.gazePoints.toLocaleString(); 502 + this.elements.debriefFixations.textContent = gazeStats.fixations.toLocaleString(); 503 + this.elements.debriefFixationDuration.textContent = gazeStats.avgFixationDuration.toLocaleString(); 504 + this.elements.sessionMessage.textContent = `Test finished via ${details?.strategy || 'manual'} completion.`; 505 + this.elements.galleryLabel.textContent = inputMode === 'mouse' 506 + ? 'Mouse-derived gaze debug session' 507 + : 'Heatmaps will appear here after a test completes.'; 216 508 217 - // Show debrief screen 218 - this.elements.debriefScreen.classList.remove('hidden'); 509 + await this.debriefRenderer.render(screens); 510 + this.elements.exportBtn.disabled = false; 219 511 } 220 512 221 - formatTime(ms) { 222 - const totalSeconds = Math.floor(ms / 1000); 223 - const minutes = Math.floor(totalSeconds / 60); 224 - const seconds = totalSeconds % 60; 225 - return `${minutes}:${seconds.toString().padStart(2, '0')}`; 226 - } 513 + exportData() { 514 + const screenRecords = this.gazeTracker.getScreenRecords().map((screen) => ({ 515 + key: screen.key, 516 + title: screen.title, 517 + firstSeenAt: screen.firstSeenAt, 518 + lastSeenAt: screen.lastSeenAt, 519 + documentWidth: screen.document.width, 520 + documentHeight: screen.document.height, 521 + viewportWidth: screen.viewport.width, 522 + viewportHeight: screen.viewport.height, 523 + screenshotCaptured: screen.screenshot.status === 'ready', 524 + screenshotWidth: screen.screenshot.width, 525 + screenshotHeight: screen.screenshot.height, 526 + gazePointCount: screen.gazePoints.length, 527 + fixationCount: screen.fixationCount 528 + })); 227 529 228 - resetSession() { 229 - this.session.reset(); 230 - this.tracker.reset(); 231 - this.clearEventLog(); 530 + this.session.exportSession({ 531 + gaze: this.gazeTracker.exportData(), 532 + interactions: this.tracker.exportData(), 533 + screens: screenRecords, 534 + debug: { 535 + mouseGazeMode: this.debugState.mouseGazeMode, 536 + calibrationSkipped: this.calibrationSkippedByDebug, 537 + inputMode: this.gazeTracker.getInputMode() 538 + } 539 + }); 540 + } 232 541 233 - // Reset UI state 234 - this.elements.appContainer.classList.remove('testing-mode'); 235 - this.elements.iframe.src = ''; 236 - this.elements.iframe.style.visibility = 'visible'; 237 - this.elements.iframePlaceholder.classList.remove('hidden'); 238 - this.elements.taskBriefing.classList.add('hidden'); 239 - 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...'; 243 - this.selectedApp = null; 244 - this.currentTask = ''; 542 + getStoredTheme() { 543 + const storedTheme = window.localStorage.getItem(this.themeStorageKey); 544 + return storedTheme === 'light' ? 'light' : 'dark'; 545 + } 245 546 246 - // Clear dropdown selection 247 - document.querySelectorAll('.dropdown-item').forEach(i => i.classList.remove('selected')); 547 + applyTheme(theme) { 548 + document.documentElement.dataset.theme = theme; 549 + this.theme = theme; 550 + const darkActive = theme === 'dark'; 248 551 249 - this.updateStats(this.tracker.createInitialStats()); 552 + this.elements.themeDarkBtn.classList.toggle('active', darkActive); 553 + this.elements.themeLightBtn.classList.toggle('active', !darkActive); 250 554 } 251 555 252 - exportData() { 253 - const trackerData = this.tracker.exportData(); 254 - this.session.exportSession(trackerData); 255 - } 556 + setTheme(theme) { 557 + if (!['dark', 'light'].includes(theme)) { 558 + return; 559 + } 256 560 257 - 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; 561 + window.localStorage.setItem(this.themeStorageKey, theme); 562 + this.applyTheme(theme); 261 563 } 262 564 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(); 565 + withThemeQuery(path) { 566 + const url = new URL(path, window.location.href); 567 + url.searchParams.set('theme', this.theme); 568 + return `${url.pathname}${url.search}${url.hash}`; 569 + } 271 570 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(); 571 + async resetRuntimeOnly() { 572 + this.winConditions.stop(); 573 + this.tracker.detach(); 574 + this.tracker.reset(); 575 + this.bridge.detach(); 576 + this.captureJobs.clear(); 577 + this.gazeTracker.reset(); 578 + this.elements.exportBtn.disabled = true; 579 + this.elements.gallery.innerHTML = ''; 580 + this.elements.galleryLabel.textContent = 'Heatmaps will appear here after a test completes.'; 581 + this.elements.calibrationScreen.classList.add('hidden'); 582 + this.elements.startCurtain.classList.add('hidden'); 583 + this.elements.startOverlay.classList.add('hidden'); 584 + this.elements.calibrationFailureOverlay.classList.add('hidden'); 585 + this.elements.debriefShell.classList.add('hidden'); 586 + this.elements.sessionStage.classList.add('hidden'); 587 + this.lastCalibrationResult = null; 588 + this.calibrationPassed = false; 589 + this.calibrationSkippedByDebug = false; 590 + this.debugState.sessionDrawerOpen = false; 276 591 277 - this.elements.totalEvents.textContent = stats.totalEvents.toLocaleString(); 592 + if (this.gazeInitialized) { 593 + await this.gazeTracker.end(); 594 + this.gazeInitialized = false; 595 + } 278 596 } 279 597 280 - addEventToLog(event) { 281 - const log = this.elements.eventLog; 598 + async resetSession() { 599 + await this.resetRuntimeOnly(); 600 + this.session.reset(); 601 + this.selectedApp = null; 602 + this.debugState.mouseGazeMode = false; 603 + this.debugState.setupPanelOpen = false; 604 + this.elements.appSelect.value = ''; 605 + this.syncSelectedAppUi(); 606 + this.elements.iframe.src = ''; 607 + this.elements.iframe.classList.add('hidden'); 608 + this.elements.sessionMessage.textContent = 'Select an app to begin.'; 609 + document.body.classList.remove('session-active'); 610 + this.syncDebugUi(); 611 + } 282 612 283 - const empty = log.querySelector('.log-empty'); 284 - if (empty) empty.remove(); 613 + failSession(message) { 614 + this.session.setState('error', { errorMessage: message }); 615 + this.elements.sessionMessage.textContent = message; 616 + document.body.classList.remove('session-active'); 617 + this.syncDebugUi(); 618 + } 285 619 286 - const entry = document.createElement('div'); 287 - entry.className = 'log-entry'; 620 + async forceMetricsRefresh() { 621 + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); 622 + const metrics = this.bridge.refreshMetrics(false); 623 + if (metrics) { 624 + this.gazeTracker.updateMetrics(metrics); 625 + } 626 + return metrics; 627 + } 288 628 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 - }); 629 + refreshMetricsIfActive() { 630 + if (!this.bridge.isAttached) { 631 + return; 632 + } 633 + const metrics = this.bridge.refreshMetrics(false); 634 + if (metrics) { 635 + this.gazeTracker.updateMetrics(metrics); 636 + } 637 + } 295 638 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 - `; 639 + updateUiForState(state, details = {}) { 640 + const sessionStates = new Set(['calibrating', 'calibration_failed', 'ready_to_start', 'recording', 'finishing']); 641 + const sessionActive = sessionStates.has(state); 301 642 302 - log.insertBefore(entry, log.firstChild); 643 + this.elements.sessionStatus.textContent = state.replace(/_/g, ' '); 644 + this.elements.exportBtn.disabled = state !== 'complete'; 645 + this.elements.setupShell.classList.toggle('hidden', state === 'calibrating' || state === 'calibration_failed' || state === 'ready_to_start' || state === 'recording' || state === 'finishing'); 646 + this.elements.sessionStage.classList.toggle('hidden', !sessionActive); 647 + this.elements.debriefShell.classList.toggle('hidden', state !== 'complete'); 648 + this.elements.calibrationScreen.classList.toggle('hidden', state !== 'calibrating'); 649 + this.elements.startCurtain.classList.toggle('hidden', state !== 'ready_to_start'); 650 + this.elements.startOverlay.classList.toggle('hidden', state !== 'ready_to_start'); 651 + this.elements.calibrationFailureOverlay.classList.toggle('hidden', state !== 'calibration_failed'); 652 + document.body.classList.toggle('session-active', sessionActive); 303 653 304 - while (log.children.length > 100) { 305 - log.removeChild(log.lastChild); 654 + switch (state) { 655 + case 'idle': 656 + this.elements.sessionMessage.textContent = 'Select an app to begin.'; 657 + this.elements.iframe.classList.add('hidden'); 658 + break; 659 + case 'loading_app': 660 + this.elements.sessionMessage.textContent = 'Loading the app and attaching instrumentation.'; 661 + this.elements.iframe.classList.remove('hidden'); 662 + break; 663 + case 'calibrating': 664 + this.elements.sessionMessage.textContent = 'Complete fullscreen calibration before the task begins.'; 665 + this.elements.iframe.classList.remove('hidden'); 666 + this.calibration.updateUi(); 667 + break; 668 + case 'calibration_failed': { 669 + const averageError = this.lastCalibrationResult?.averageError; 670 + const summary = Number.isFinite(averageError) 671 + ? `Average error: ${averageError}px.` 672 + : 'Calibration could not be scored accurately.'; 673 + this.elements.calibrationFailureSummary.textContent = summary; 674 + this.elements.sessionMessage.textContent = 'Calibration failed. Review the result and retry.'; 675 + break; 676 + } 677 + case 'ready_to_start': 678 + this.elements.sessionMessage.textContent = this.debugState.mouseGazeMode 679 + ? 'Debug mode is armed. The app stays hidden until recording starts.' 680 + : this.calibrationSkippedByDebug 681 + ? 'Calibration was skipped in debug mode. The app is loaded and still hidden.' 682 + : 'Calibration passed. Start the task to reveal the app and begin recording.'; 683 + this.elements.startOverlayTask.textContent = this.selectedApp?.task || 'Complete the assigned task.'; 684 + break; 685 + case 'recording': 686 + this.elements.sessionMessage.textContent = this.debugState.mouseGazeMode 687 + ? 'Recording fullscreen session with mouse-derived gaze.' 688 + : 'Recording fullscreen session.'; 689 + break; 690 + case 'finishing': 691 + this.elements.sessionMessage.textContent = 'Finalizing screenshots and rendering debrief...'; 692 + break; 693 + case 'complete': 694 + this.elements.sessionMessage.textContent = this.elements.sessionMessage.textContent || 'Debrief ready.'; 695 + document.body.classList.remove('session-active'); 696 + break; 697 + case 'error': 698 + this.elements.sessionMessage.textContent = details.errorMessage || 'The session entered an error state.'; 699 + this.elements.sessionStage.classList.add('hidden'); 700 + document.body.classList.remove('session-active'); 701 + break; 306 702 } 307 - } 308 703 309 - clearEventLog() { 310 - this.elements.eventLog.innerHTML = '<div class="log-empty">No events recorded</div>'; 704 + this.syncDebugUi(); 311 705 } 312 706 } 313 707 314 - // Initialize app when DOM is ready 315 708 document.addEventListener('DOMContentLoaded', () => { 316 709 window.uxetApp = new UXETApp(); 317 710 });
+65 -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 + 'calibration_failed', 10 + 'ready_to_start', 11 + 'recording', 12 + 'finishing', 13 + 'complete', 14 + 'error' 15 + ]); 16 + 5 17 export class Session { 6 18 constructor() { 7 - this.status = 'idle'; // idle, recording, stopped 19 + this.state = 'idle'; 8 20 this.startTime = null; 9 21 this.elapsed = 0; 10 22 this.timerInterval = null; 11 23 this.appName = ''; 12 24 this.task = ''; 13 - this.onStatusChange = null; 25 + this.errorMessage = ''; 26 + this.onStateChange = null; 14 27 this.onTimerUpdate = null; 15 28 } 16 29 17 - start() { 18 - if (this.status === 'recording') return; 30 + setState(nextState, details = {}) { 31 + if (!VALID_STATES.has(nextState)) { 32 + throw new Error(`Invalid session state: ${nextState}`); 33 + } 19 34 20 - this.status = 'recording'; 21 - this.startTime = Date.now() - this.elapsed; 35 + this.state = nextState; 36 + if (details.errorMessage) { 37 + this.errorMessage = details.errorMessage; 38 + } else if (nextState !== 'error') { 39 + this.errorMessage = ''; 40 + } 22 41 23 - this.timerInterval = setInterval(() => { 42 + this.notifyStateChange(details); 43 + } 44 + 45 + startRecording() { 46 + if (this.state === 'recording') { 47 + return; 48 + } 49 + 50 + this.startTime = Date.now(); 51 + this.elapsed = 0; 52 + this.setState('recording'); 53 + 54 + this.timerInterval = window.setInterval(() => { 24 55 this.elapsed = Date.now() - this.startTime; 25 56 if (this.onTimerUpdate) { 26 57 this.onTimerUpdate(this.formatTime(this.elapsed)); 27 58 } 28 59 }, 100); 29 - 30 - this.notifyStatusChange(); 31 60 } 32 61 33 - stop() { 34 - if (this.status !== 'recording') return; 35 - 36 - this.status = 'stopped'; 37 - this.elapsed = Date.now() - this.startTime; 62 + stopRecording(nextState = 'complete') { 63 + if (this.startTime) { 64 + this.elapsed = Date.now() - this.startTime; 65 + } 38 66 39 67 if (this.timerInterval) { 40 68 clearInterval(this.timerInterval); 41 69 this.timerInterval = null; 42 70 } 43 71 44 - this.notifyStatusChange(); 72 + this.setState(nextState); 73 + 74 + if (this.onTimerUpdate) { 75 + this.onTimerUpdate(this.formatTime(this.elapsed)); 76 + } 45 77 } 46 78 47 79 reset() { 48 - this.status = 'idle'; 49 - this.startTime = null; 50 - this.elapsed = 0; 51 - this.task = ''; 52 - 53 80 if (this.timerInterval) { 54 81 clearInterval(this.timerInterval); 55 82 this.timerInterval = null; 56 83 } 57 84 85 + this.state = 'idle'; 86 + this.startTime = null; 87 + this.elapsed = 0; 88 + this.task = ''; 89 + this.appName = ''; 90 + this.errorMessage = ''; 91 + 58 92 if (this.onTimerUpdate) { 59 93 this.onTimerUpdate('00:00:00'); 60 94 } 61 95 62 - this.notifyStatusChange(); 96 + this.notifyStateChange(); 63 97 } 64 98 65 99 setAppName(name) { ··· 77 111 const seconds = totalSeconds % 60; 78 112 79 113 return [hours, minutes, seconds] 80 - .map(n => n.toString().padStart(2, '0')) 114 + .map((n) => n.toString().padStart(2, '0')) 81 115 .join(':'); 82 116 } 83 117 84 - notifyStatusChange() { 85 - if (this.onStatusChange) { 86 - this.onStatusChange(this.status); 118 + notifyStateChange(details = {}) { 119 + if (this.onStateChange) { 120 + this.onStateChange(this.state, details); 87 121 } 88 122 } 89 123 ··· 91 125 return { 92 126 appName: this.appName, 93 127 task: this.task, 94 - status: this.status, 128 + status: this.state, 95 129 startTime: this.startTime ? new Date(this.startTime).toISOString() : null, 96 - duration: this.elapsed 130 + duration: this.elapsed, 131 + errorMessage: this.errorMessage || null 97 132 }; 98 133 } 99 134 100 - exportSession(trackerData) { 135 + exportSession(payload) { 101 136 const data = { 102 137 session: this.getMetadata(), 103 - ...trackerData 138 + ...payload 104 139 }; 105 140 106 141 const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
+145 -164
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 - } 18 - 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 - }; 37 + this.onMousePosition = null; 38 38 } 39 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) { 40 + attach(bridge) { 50 41 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); 42 + this.bridge = bridge; 43 + const doc = bridge.iframeDocument; 44 + const win = bridge.iframeWindow; 45 + if (!doc || !win) { 77 46 return false; 78 47 } 48 + 49 + this.addListener(doc, 'mousemove', (event) => this.handleMouseMove(event), { capture: true, passive: true }); 50 + this.addListener(doc, 'click', (event) => this.handleClick(event), true); 51 + this.addListener(doc, 'keydown', (event) => this.handleKeyDown(event), true); 52 + this.addListener(win, 'scroll', (event) => this.handleScroll(event), { passive: true }); 53 + this.addListener(doc, 'scroll', (event) => this.handleScroll(event), { capture: true, passive: true }); 54 + return true; 79 55 } 80 56 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 }); 57 + addListener(target, eventName, handler, options = false) { 58 + target.addEventListener(eventName, handler, options); 59 + this.detachFns.push(() => target.removeEventListener(eventName, handler, options)); 88 60 } 89 61 90 62 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; 63 + this.detachFns.forEach((fn) => fn()); 64 + this.detachFns = []; 65 + this.bridge = null; 102 66 } 103 67 104 68 start() { ··· 110 74 this.isRecording = false; 111 75 } 112 76 113 - handleMouseMove(e) { 114 - if (!this.isRecording) return; 77 + reset() { 78 + this.stop(); 79 + this.events = []; 80 + this.stats = createInitialStats(); 81 + this.lastMousePos = null; 82 + this.lastMouseTime = null; 83 + this.velocities = []; 84 + } 85 + 86 + handleMouseMove(event) { 87 + if (!this.isRecording) { 88 + return; 89 + } 115 90 116 91 const now = Date.now(); 117 - const x = e.clientX; 118 - const y = e.clientY; 92 + const metrics = this.bridge.getMetricsSnapshot(); 93 + const iframeX = Math.round(event.clientX); 94 + const iframeY = Math.round(event.clientY); 95 + const docX = iframeX + metrics.scrollX; 96 + const docY = iframeY + metrics.scrollY; 119 97 120 - // Calculate distance and velocity 121 98 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); 99 + const distance = Math.hypot(docX - this.lastMousePos.x, docY - this.lastMousePos.y); 125 100 this.stats.mouse.distance += distance; 126 - 127 101 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 102 + const seconds = (now - this.lastMouseTime) / 1000; 103 + if (seconds > 0) { 104 + this.velocities.push(distance / seconds); 133 105 if (this.velocities.length > 50) { 134 106 this.velocities.shift(); 135 107 } 136 108 this.stats.mouse.avgVelocity = Math.round( 137 - this.velocities.reduce((a, b) => a + b, 0) / this.velocities.length 109 + this.velocities.reduce((sum, value) => sum + value, 0) / this.velocities.length 138 110 ); 139 111 } 140 112 } 141 113 } 142 114 143 - this.lastMousePos = { x, y }; 115 + this.lastMousePos = { x: docX, y: docY }; 144 116 this.lastMouseTime = now; 117 + this.stats.mouse.x = docX; 118 + this.stats.mouse.y = docY; 119 + this.stats.mouse.movements += 1; 120 + this.stats.totalEvents += 1; 145 121 146 - this.stats.mouse.x = x; 147 - this.stats.mouse.y = y; 148 - this.stats.mouse.movements++; 149 - this.stats.totalEvents++; 122 + if (this.onMousePosition) { 123 + this.onMousePosition({ 124 + timestamp: now, 125 + clientX: iframeX, 126 + clientY: iframeY, 127 + docX, 128 + docY, 129 + metrics 130 + }); 131 + } 150 132 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})`); 133 + if (this.stats.mouse.movements % 12 === 0) { 134 + this.logEvent('mouse', 'mousemove', event, metrics, { docX, docY }); 154 135 } 155 - 156 - this.notifyStatsUpdate(); 157 136 } 158 137 159 - handleClick(e) { 160 - if (!this.isRecording) return; 138 + handleClick(event) { 139 + if (!this.isRecording) { 140 + return; 141 + } 142 + const metrics = this.bridge.getMetricsSnapshot(); 143 + this.stats.mouse.clicks += 1; 144 + this.stats.totalEvents += 1; 145 + const target = event.target; 146 + const tagName = target?.tagName?.toLowerCase() || 'unknown'; 147 + const descriptor = [ 148 + tagName, 149 + target?.id ? `#${target.id}` : '', 150 + target?.className && typeof target.className === 'string' 151 + ? `.${target.className.split(' ').filter(Boolean)[0] || ''}` 152 + : '' 153 + ].join(''); 161 154 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(); 155 + this.logEvent('click', `click:${descriptor}`, event, metrics, { 156 + docX: Math.round(event.clientX + metrics.scrollX), 157 + docY: Math.round(event.clientY + metrics.scrollY) 158 + }); 172 159 } 173 160 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(); 161 + handleScroll() { 162 + if (!this.isRecording) { 163 + return; 164 + } 165 + const metrics = this.bridge.getMetricsSnapshot(); 166 + this.stats.mouse.scrollEvents += 1; 167 + this.stats.totalEvents += 1; 168 + this.logEvent('scroll', 'scroll', null, metrics); 182 169 } 183 170 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); 171 + handleKeyDown(event) { 172 + if (!this.isRecording) { 173 + return; 174 + } 175 + const metrics = this.bridge.getMetricsSnapshot(); 176 + this.stats.keyboard.totalKeys += 1; 177 + this.stats.totalEvents += 1; 178 + const elapsedMinutes = (Date.now() - this.startTime) / 60000; 179 + if (elapsedMinutes > 0) { 180 + this.stats.keyboard.keysPerMinute = Math.round(this.stats.keyboard.totalKeys / elapsedMinutes); 194 181 } 195 - 196 - // Track backspaces 197 - if (e.key === 'Backspace') { 198 - this.stats.keyboard.backspaces++; 182 + if (event.key === 'Backspace') { 183 + this.stats.keyboard.backspaces += 1; 199 184 } 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(); 185 + this.stats.keyboard.lastKey = event.key.length > 1 ? `[${event.key}]` : event.key; 186 + this.logEvent('key', `key:${this.stats.keyboard.lastKey}`, event, metrics); 210 187 } 211 188 212 - logEvent(type, message) { 213 - const event = { 189 + logEvent(type, message, event, metrics, extra = {}) { 190 + const payload = { 214 191 timestamp: Date.now(), 215 192 type, 216 193 message, 217 - stats: { ...this.stats } 194 + screenKey: metrics?.key || 'unknown', 195 + scrollX: metrics?.scrollX || 0, 196 + scrollY: metrics?.scrollY || 0, 197 + viewportWidth: metrics?.viewportWidth || 0, 198 + viewportHeight: metrics?.viewportHeight || 0, 199 + documentWidth: metrics?.documentWidth || 0, 200 + documentHeight: metrics?.documentHeight || 0, 201 + targetTag: event?.target?.tagName?.toLowerCase() || null, 202 + ...extra 218 203 }; 219 - this.events.push(event); 220 204 205 + this.events.push(payload); 221 206 if (this.onEvent) { 222 - this.onEvent(event); 207 + this.onEvent(payload); 223 208 } 224 209 } 225 210 226 - notifyStatsUpdate() { 227 - if (this.onStatsUpdate) { 228 - this.onStatsUpdate({ ...this.stats }); 229 - } 211 + getStats() { 212 + return { 213 + mouse: { ...this.stats.mouse }, 214 + keyboard: { ...this.stats.keyboard }, 215 + totalEvents: this.stats.totalEvents 216 + }; 230 217 } 231 218 232 219 getEvents() { 233 220 return [...this.events]; 234 221 } 235 222 236 - getStats() { 237 - return { ...this.stats }; 238 - } 239 - 240 223 exportData() { 241 224 return { 242 - exportedAt: new Date().toISOString(), 243 - duration: this.startTime ? Date.now() - this.startTime : 0, 244 225 stats: this.getStats(), 245 226 events: this.getEvents() 246 227 };
+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
+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>
+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>
-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 });