cyborg-hunter 0.5.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/CITATION.cff +29 -0
  3. package/LICENSE +21 -0
  4. package/README.md +80 -21
  5. package/bin/cyborg-hunter.js +6 -2
  6. package/dist/cyborg-hunter-replay.js +3 -0
  7. package/dist/cyborg-hunter.esm.js +114 -22
  8. package/dist/cyborg-hunter.min.js +3 -3
  9. package/dist/extension-cyborg-hunter.js +1 -1
  10. package/dist/extension-guard-friction.js +5 -5
  11. package/dist/extension-guard-honeypot.js +1 -1
  12. package/package.json +15 -3
  13. package/src/cli/analyzers/edge-exit.js +4 -1
  14. package/src/cli/analyzers/phase-scope.js +83 -0
  15. package/src/cli/analyzers/summary.js +161 -28
  16. package/src/cli/analyzers/triage.js +59 -27
  17. package/src/cli/config.js +26 -1
  18. package/src/cli/extract-core.js +552 -0
  19. package/src/cli/ingest.js +250 -193
  20. package/src/cli/init.js +1 -1
  21. package/src/cli/preview-entry.js +36 -0
  22. package/src/cli/renderers/event-log.js +18 -19
  23. package/src/cli/renderers/extensions.js +12 -3
  24. package/src/cli/renderers/html-index-core.js +1273 -0
  25. package/src/cli/renderers/html-index.js +13 -1047
  26. package/src/cli/renderers/replay-assets.js +67 -0
  27. package/src/cli/renderers/replay-viewer.client.js +1185 -0
  28. package/src/cli/renderers/session-timeline-core.js +907 -0
  29. package/src/cli/renderers/session-timeline.js +33 -0
  30. package/src/cli/renderers/summary-csv.js +5 -0
  31. package/src/cli/renderers/trajectories-core.js +717 -0
  32. package/src/cli/renderers/trajectories.js +31 -635
  33. package/src/cli/renderers/triage-md.js +10 -4
  34. package/src/cli/renderers/typing-profile-core.js +211 -0
  35. package/src/cli/renderers/typing-profile.js +16 -186
  36. package/src/cli/report.js +42 -8
  37. package/src/core/monitor.js +77 -7
  38. package/src/core/scoring.js +11 -2
  39. package/src/core/signals/browser.js +51 -18
  40. package/src/core/signals/clipboard.js +10 -2
  41. package/src/core/signals/dom-protection.js +9 -0
  42. package/src/core/signals/focus.js +16 -2
  43. package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
  44. package/src/jspsych/extension-cyborg-hunter.js +9 -2
  45. package/src/jspsych/extension-guard-friction.js +43 -14
  46. package/src/jspsych/extension-guard-honeypot.js +25 -1
  47. package/src/replay/capture-dom.js +575 -0
  48. package/src/replay/capture-trace.js +468 -0
  49. package/src/replay/index.js +104 -0
  50. package/src/replay/persistence.js +141 -0
  51. package/src/replay/recorder.js +315 -0
  52. package/src/replay/serializer.js +119 -0
  53. package/src/replay/viewer-model.js +125 -0
  54. package/src/shared/constants.js +12 -6
  55. package/src/shared/paths.js +20 -0
  56. package/src/shared/schema.js +5 -0
  57. package/src/shared/validation.js +55 -0
  58. package/src/cli/renderers/tab-timeline.js +0 -149
@@ -1,1054 +1,20 @@
1
1
  // src/cli/renderers/html-index.js
2
- // Generates the self-contained cohort-triage HTML report page.
3
- //
4
- // Layout (Task 3 — left rail filled in; Tasks 4-7 will fill the detail pane):
5
- // - Two-column grid: 360px sidebar rail + flexible detail pane
6
- // - Both columns scroll independently; full-viewport height
7
- // - Topbar carries title, "{N} participants · v{VERSION}" metadata, Legend button
8
- // - Left rail: search, filter chips, sort selector, scrolling cohort rows,
9
- // sticky totals footer (interactivity is wired in Tasks 9–10)
10
- // - Empty placeholders for: legend modal, lightbox, IIFE script
11
- //
12
- // References linked images in images/ (not base64-embedded). Works offline.
13
-
14
- import { writeFileSync } from 'fs';
2
+ // Thin Node wrapper around the pure render core (html-index-core.js):
3
+ // reads the replay viewer client from disk, writes index.html to outputDir.
4
+ // Public API unchanged — report.js and adopters keep calling renderHtmlIndex.
5
+ import { writeFileSync, readFileSync } from 'fs';
15
6
  import { join } from 'path';
16
- import { VERSION } from '../../shared/constants.js';
17
- import { decomposeScore } from '../analyzers/triage.js';
18
-
19
- export async function renderHtmlIndex(summaries, triage, participants, config, visualsRendered) {
20
- // Cohort counts for filter chips and totals footer. The triage array is
21
- // already sorted descending by score by triage.js — we don't re-sort here.
22
- const hard = triage.filter(t => t.hardTriggered).length;
23
- const soft = triage.filter(t => !t.hardTriggered && t.softFlagged).length;
24
- const clean = triage.length - hard - soft;
25
- const cohortCounts = { hard, soft, clean };
26
- const railHtml = renderCohortList(triage, cohortCounts);
27
-
28
- // Detail panes: one <section class="participant"> per triage entry. Only the
29
- // first is visible by default; Tasks 9–11 wire row clicks to toggle the
30
- // `hidden` attribute on the others. Each pane carries its own
31
- // `visualsRendered=false` fallback note inline (see renderDetail), so we no
32
- // longer need a top-level swap — the per-participant fallback is the contract
33
- // exercised by the second smoke test.
34
- const detailHtml = triage.map((t, i) => {
35
- const participant = participants.find(p => p.participantId === t.participantId);
36
- return renderDetail(t, participant, visualsRendered, /* defaultVisible */ i === 0);
37
- }).join('\n');
38
-
39
- const html = `<!DOCTYPE html>
40
- <html lang="en">
41
- <head>
42
- <meta charset="utf-8">
43
- <meta name="viewport" content="width=device-width, initial-scale=1">
44
- <title>Cyborg Hunter Report</title>
45
- <style>
46
- :root {
47
- --bg: #fafafa; --surface: #fff; --ink: #1a1814;
48
- --dim: #706a5c; --line: #e2ddd1;
49
- --hard: #d32f2f; --soft: #f57c00; --clean: #388e3c;
50
- }
51
- * { box-sizing: border-box; margin: 0; padding: 0; }
52
- html, body { height: 100%; }
53
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
54
- color: var(--ink); background: var(--bg); display: grid; grid-template-rows: auto 1fr; height: 100vh; }
55
- .topbar { display: flex; align-items: center; gap: 16px; padding: 12px 20px;
56
- background: var(--surface); border-bottom: 1px solid var(--line); }
57
- .topbar h1 { font-size: 18px; font-weight: 600; }
58
- .topbar .meta { color: var(--dim); font-size: 13px; flex: 1; }
59
- .topbar button { padding: 6px 12px; border: 1px solid var(--line); background: var(--surface);
60
- color: var(--ink); border-radius: 4px; cursor: pointer; font-size: 13px; }
61
- .topbar button:hover { background: var(--bg); }
62
- .layout { display: grid; grid-template-columns: 360px 1fr; height: 100%; overflow: hidden; }
63
- .rail { background: var(--surface); border-right: 1px solid var(--line); overflow-y: auto; }
64
- .detail { overflow-y: auto; padding: 20px; }
65
- .note { color: var(--dim); font-style: italic; font-size: 13px; }
66
- .mono {
67
- font: 13px/1.3 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
68
- font-variant-numeric: tabular-nums;
69
- }
70
- [hidden] { display: none !important; }
71
-
72
- /* Rail layout — fixed top band, scrolling middle, sticky footer */
73
- .rail { display: flex; flex-direction: column; }
74
- .rail-top {
75
- padding: 12px; border-bottom: 1px solid var(--line);
76
- display: flex; flex-direction: column; gap: 8px;
77
- }
78
- .rail-list { flex: 1; overflow-y: auto; }
79
- .rail-footer {
80
- padding: 10px 12px; border-top: 1px solid var(--line);
81
- background: var(--surface); font-size: 12px;
82
- }
83
- .totals-title { color: var(--dim); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; }
84
- .totals-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; }
85
- .totals-row .count { margin-left: auto; }
86
-
87
- /* Search */
88
- .search-wrap input {
89
- width: 100%; padding: 6px 8px; border: 1px solid var(--line);
90
- border-radius: 4px; font-size: 13px; background: var(--bg);
91
- color: var(--ink);
92
- }
93
- .search-wrap input:focus { outline: 1px solid var(--ink); border-color: var(--ink); }
94
-
95
- /* Filter chips */
96
- .filter-chips { display: flex; flex-wrap: wrap; gap: 4px; }
97
- .filter-chip {
98
- padding: 3px 8px; border: 1px solid var(--line); background: var(--surface);
99
- color: var(--ink); border-radius: 12px; cursor: pointer; font-size: 12px;
100
- }
101
- .filter-chip:hover { background: var(--bg); }
102
- .filter-chip.active {
103
- background: var(--ink); color: var(--surface); border-color: var(--ink);
104
- }
105
-
106
- /* Sort selector */
107
- .sort-wrap { font-size: 12px; color: var(--dim); }
108
- .sort-wrap select {
109
- background: var(--surface); color: var(--ink); border: 1px solid var(--line);
110
- border-radius: 4px; padding: 2px 4px; font-size: 12px;
111
- }
112
-
113
- /* Cohort rows */
114
- .cohort-row {
115
- padding: 10px 12px; border-bottom: 1px solid var(--line);
116
- cursor: pointer;
117
- }
118
- .cohort-row:hover { background: var(--bg); }
119
- .cohort-row.selected {
120
- border-left: 4px solid var(--ink);
121
- padding-left: 8px; /* compensate for the 4px left border */
122
- background: var(--bg);
123
- }
124
- .cohort-row-top { display: flex; align-items: center; gap: 8px; }
125
- .cohort-row-top .pid {
126
- flex: 1; min-width: 0; color: var(--ink);
127
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
128
- }
129
- .cohort-row-top .score { color: var(--dim); flex-shrink: 0; }
130
- .cohort-row.selected .cohort-row-top .score { color: var(--ink); font-weight: 600; }
131
- .cohort-row-bot {
132
- display: flex; align-items: flex-start; gap: 8px;
133
- margin-top: 4px;
134
- }
135
- .reason-excerpt {
136
- flex: 1; color: var(--dim); font-size: 12px;
137
- display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
138
- overflow: hidden; text-overflow: ellipsis;
139
- }
140
-
141
- /* Tier dot — small filled circle, color from tier */
142
- .tier-dot {
143
- width: 8px; height: 8px; border-radius: 50%;
144
- flex-shrink: 0;
145
- }
146
- .tier-dot[data-tier="hard"] { background: var(--hard); }
147
- .tier-dot[data-tier="soft"] { background: var(--soft); }
148
- .tier-dot[data-tier="clean"] { background: var(--clean); }
149
-
150
- /* Tier badge — small pill, filled for hard/soft, outline for clean */
151
- .tier-badge {
152
- font-size: 10px; padding: 2px 6px; border-radius: 3px;
153
- font-weight: 600; letter-spacing: 0.04em; flex-shrink: 0;
154
- }
155
- .tier-badge[data-tier="hard"] { background: var(--hard); color: white; }
156
- .tier-badge[data-tier="soft"] { background: var(--soft); color: white; }
157
- .tier-badge[data-tier="clean"] {
158
- background: transparent; color: var(--dim);
159
- border: 1px solid var(--line); font-weight: 400;
160
- }
161
-
162
- /* Detail pane base */
163
- .detail .participant { max-width: 800px; }
164
-
165
- /* Empty-state hint when filter+search combine to hide every row.
166
- Shown by reconcileSelection() via [data-empty="true"] on .detail. */
167
- .detail[data-empty="true"]::before {
168
- content: "No participants match. Try clearing the filter or search.";
169
- display: block;
170
- padding: 40px 0;
171
- color: var(--dim);
172
- font-size: 14px;
173
- text-align: center;
174
- }
175
-
176
- /* Filter chip selectors — pure CSS hiding via body[data-filter] attribute.
177
- JS just flips the data attribute; the actual hiding lives here so
178
- interactivity degrades gracefully (rows still show with JS disabled). */
179
- body[data-filter="hard"] .cohort-row:not([data-tier="hard"]) { display: none; }
180
- body[data-filter="soft"] .cohort-row:not([data-tier="soft"]) { display: none; }
181
- body[data-filter="clean"] .cohort-row:not([data-tier="clean"]) { display: none; }
182
- .cohort-row.search-hidden { display: none; }
183
-
184
- /* Header strip */
185
- .detail-header {
186
- border-bottom: 1px solid var(--line);
187
- padding-bottom: 12px; margin-bottom: 16px;
188
- }
189
- .detail-header-top { display: flex; align-items: center; gap: 12px; }
190
- .detail-header-top .pid-full { flex: 1; font-size: 13px; color: var(--ink); }
191
- .detail-header-top .score-big { font-size: 36px; font-weight: 600; color: var(--ink); }
192
- .detail-header-sub { color: var(--dim); font-size: 12px; margin-top: 4px; }
193
-
194
- /* Tier pill — bigger version of the tier badge in the rail */
195
- .tier-pill {
196
- font-size: 11px; padding: 3px 8px; border-radius: 4px;
197
- font-weight: 600; letter-spacing: 0.04em;
198
- }
199
- .tier-pill[data-tier="hard"] { background: var(--hard); color: white; }
200
- .tier-pill[data-tier="soft"] { background: var(--soft); color: white; }
201
- .tier-pill[data-tier="clean"] {
202
- background: transparent; color: var(--dim);
203
- border: 1px solid var(--line); font-weight: 400;
204
- }
205
-
206
- /* Signal grid — colour-coded tiles, one per tracked signal in SIGNALS taxonomy.
207
- Every tile renders for every participant (hits and misses), so the layout
208
- is stable across panes. Only firing signals light up; misses fade to muted. */
209
- .signal-grid {
210
- display: grid;
211
- grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
212
- gap: 6px;
213
- margin: 12px 0 16px;
214
- }
215
- .signal-tile {
216
- border: 1px solid var(--line);
217
- border-radius: 4px;
218
- padding: 8px 10px;
219
- display: flex;
220
- flex-direction: column;
221
- gap: 2px;
222
- background: transparent;
223
- color: var(--dim);
224
- font-variant-numeric: tabular-nums;
225
- cursor: help;
226
- }
227
- .signal-tile.tone-critical { color: var(--hard); border-color: var(--hard); background: rgba(211, 47, 47, 0.04); }
228
- .signal-tile.tone-warn { color: var(--soft); border-color: var(--soft); background: rgba(245, 124, 0, 0.04); }
229
- .signal-tile.tone-muted { color: var(--ink); border-color: var(--ink); background: rgba(26, 24, 20, 0.03); }
230
- .signal-tile.tone-zero { color: #b8b1a0; border-color: #ebe6d8; background: transparent; }
231
- .signal-value { font-size: 18px; font-weight: 700; line-height: 1; }
232
- .signal-label { font-size: 10px; letter-spacing: 0.4px; text-transform: uppercase; opacity: 0.85; }
233
-
234
- /* Score breakdown — horizontal flex of weighted contributions to t.score,
235
- ending in "Total: N". Only non-zero terms render; the bar widths are
236
- proportional to each term's share of the total. */
237
- .score-breakdown {
238
- display: flex; flex-wrap: wrap; gap: 10px 16px;
239
- align-items: center; margin-bottom: 16px;
240
- }
241
- .score-term {
242
- display: inline-flex; gap: 6px; align-items: center;
243
- font-size: 12px;
244
- }
245
- .score-term .label { color: var(--dim); }
246
- .score-term .contrib { color: var(--ink); }
247
- .score-term .bar {
248
- height: 6px; background: var(--ink); opacity: 0.4; border-radius: 3px;
249
- }
250
- .score-total { margin-left: auto; font-weight: 600; color: var(--ink); }
251
-
252
- /* Reason pull-quote — warm-tinted block-quote with a left rule. The bg
253
- color is picked by hand from --bg's family rather than computed, so
254
- it shifts cleanly when the palette changes. */
255
- .reason {
256
- border-left: 3px solid var(--ink);
257
- background: #f5f1e8;
258
- padding: 10px 14px;
259
- margin: 0 0 16px;
260
- font-size: 14px;
261
- color: var(--ink);
262
- }
263
-
264
- /* Session-level signals — small cards in a responsive grid. The grid
265
- collapses from 3 cells to 1 in narrow detail panes via auto-fit. */
266
- .section-heading {
267
- font-size: 13px; color: var(--dim);
268
- text-transform: uppercase; letter-spacing: 0.04em;
269
- margin: 18px 0 8px;
270
- }
271
- .session-grid {
272
- display: grid;
273
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
274
- gap: 12px;
275
- }
276
- .sig-cell {
277
- background: var(--surface); border: 1px solid var(--line);
278
- border-radius: 6px; padding: 10px;
279
- }
280
- .sig-cell-title {
281
- font-size: 11px; color: var(--dim);
282
- text-transform: uppercase; letter-spacing: 0.04em;
283
- }
284
- .sig-cell ul { list-style: none; margin-top: 4px; font-size: 12px; padding: 0; }
285
- .sig-cell li { padding: 2px 0; word-break: break-word; }
286
- .muted { color: var(--dim); }
287
-
288
- /* Paste evidence — list of expandable entries */
289
- .paste-list { display: flex; flex-direction: column; gap: 6px; }
290
- .paste-entry {
291
- display: flex; gap: 8px; align-items: flex-start;
292
- }
293
- .paste-toggle {
294
- background: none; border: none; color: var(--dim);
295
- cursor: pointer; font-size: 14px; line-height: 1.2;
296
- padding: 0 4px; flex-shrink: 0;
297
- }
298
- .paste-toggle:disabled { cursor: default; }
299
- .paste-toggle:hover:not(:disabled) { color: var(--ink); }
300
- .paste-trial { color: var(--dim); flex-shrink: 0; }
301
- .paste-preview, .paste-full {
302
- background: #f5f1e8; /* warm tinted bg, same as the reason pull-quote */
303
- padding: 4px 8px; border-radius: 3px;
304
- font-size: 12px;
305
- word-break: break-word; white-space: pre-wrap;
306
- flex: 1; min-width: 0; /* allow flex item to shrink and wrap correctly */
307
- }
308
- /* When expanded by Task 11's JS: hide preview, show full text */
309
- .paste-entry.expanded .paste-preview { display: none; }
310
- .paste-entry:not(.expanded) .paste-full { display: none; }
311
- .paste-overflow { font-size: 12px; margin-top: 4px; }
312
-
313
- /* Image blocks — wrapper containers so a missing PNG hides its heading too.
314
- Each .image-block contains a <h4 class="section-heading"> + a zoomable
315
- <a> wrapping an <img>; if the image fails to load, onerror hides the
316
- whole block (no orphan heading). */
317
- .image-block { margin: 18px 0; }
318
- .image-block:first-of-type { margin-top: 0; } /* tighten when image is the first thing in the pane */
319
- /* Override .section-heading's default top margin inside .image-block, so
320
- the wrapper's margin: 18px 0 doesn't stack with the heading's own margin. */
321
- .image-block .section-heading { margin: 0 0 6px; }
322
- .detail img {
323
- max-width: 100%; display: block; margin: 6px 0 0;
324
- border: 1px solid var(--line); border-radius: 4px;
325
- }
326
- .zoomable {
327
- display: block; cursor: zoom-in;
328
- text-decoration: none;
329
- }
330
- .zoomable:hover img { opacity: 0.92; }
331
-
332
- /* Lightbox overlay — hidden by default; Task 11's JS adds .open to show it. */
333
- .lightbox-overlay {
334
- position: fixed; inset: 0; background: rgba(0, 0, 0, 0.85);
335
- z-index: 1000; display: none;
336
- align-items: center; justify-content: center;
337
- cursor: zoom-out; padding: 20px;
338
- }
339
- .lightbox-overlay.open { display: flex; }
340
- .lightbox-overlay img {
341
- max-width: 100%; max-height: 100%; border-radius: 4px;
342
- }
343
- .lightbox-close {
344
- position: fixed; top: 16px; right: 24px;
345
- color: white; font-size: 32px; cursor: pointer;
346
- background: rgba(0, 0, 0, 0.4); border-radius: 50%;
347
- width: 40px; height: 40px;
348
- display: flex; align-items: center; justify-content: center; line-height: 1;
349
- }
350
-
351
- /* Legend modal — opens via Task 11's JS by removing the [hidden] attribute. */
352
- #legend-modal {
353
- position: fixed; inset: 0; z-index: 900;
354
- }
355
- .modal-backdrop {
356
- position: absolute; inset: 0; background: rgba(0, 0, 0, 0.45);
357
- }
358
- .modal-card {
359
- position: relative;
360
- background: var(--surface);
361
- max-width: 560px; max-height: calc(100vh - 16vh);
362
- margin: 8vh auto 0;
363
- padding: 20px 24px;
364
- border-radius: 8px;
365
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
366
- overflow-y: auto;
367
- }
368
- .modal-close {
369
- position: absolute; top: 8px; right: 12px;
370
- background: none; border: none;
371
- font-size: 24px; line-height: 1;
372
- cursor: pointer; color: var(--dim);
373
- padding: 4px 8px;
374
- }
375
- .modal-close:hover { color: var(--ink); }
376
- .modal-card h3 {
377
- font-size: 16px; margin-bottom: 12px;
378
- }
379
- .legend-table {
380
- width: 100%; border-collapse: collapse;
381
- font-size: 13px;
382
- }
383
- .legend-table th, .legend-table td {
384
- text-align: left; padding: 6px 8px;
385
- border-bottom: 1px solid var(--line);
386
- vertical-align: top;
387
- }
388
- .legend-table th {
389
- color: var(--dim); font-weight: 500; font-size: 12px;
390
- text-transform: uppercase; letter-spacing: 0.04em;
391
- }
392
- .legend-table td:first-child {
393
- color: var(--ink); font-weight: 500;
394
- white-space: nowrap;
395
- }
396
- </style>
397
- </head>
398
- <body data-filter="all">
399
- <header class="topbar">
400
- <h1>Cyborg Hunter Report</h1>
401
- <span class="meta">${triage.length} participants &middot; v${VERSION}</span>
402
- <button class="legend-btn" type="button" aria-haspopup="dialog" aria-controls="legend-modal">Legend &#9432;</button>
403
- </header>
404
- <div class="layout">
405
- <aside class="rail">${railHtml}</aside>
406
- <section class="detail">${detailHtml}</section>
407
- </div>
408
-
409
- <div id="legend-modal" hidden role="dialog" aria-label="Signal legend">
410
- <div class="modal-backdrop"></div>
411
- <div class="modal-card">
412
- <button class="modal-close" type="button" aria-label="Close">&times;</button>
413
- <h3>Signal reference</h3>
414
- <table class="legend-table">
415
- <thead>
416
- <tr><th>Signal</th><th>Meaning</th></tr>
417
- </thead>
418
- <tbody>
419
- <tr><td>paste</td> <td>Clipboard paste into the response field (hard signal).</td></tr>
420
- <tr><td>copy</td> <td>Clipboard copy from the page (soft signal).</td></tr>
421
- <tr><td>drop</td> <td>Drag-and-drop into the response field (hard signal).</td></tr>
422
- <tr><td>tab-away &ge;10s</td> <td>Left the page for &ge;10 seconds; counts toward soft score.</td></tr>
423
- <tr><td>tab-away 3&ndash;10s</td><td>Left the page briefly; counts toward soft score.</td></tr>
424
- <tr><td>flicker &lt;3s</td> <td>Very brief blur; reported but not scored.</td></tr>
425
- <tr><td>sidebar event</td> <td>Viewport width shrank suddenly &mdash; likely AI sidebar opened.</td></tr>
426
- <tr><td>AI extension</td> <td>Browser extension known for AI assistance detected.</td></tr>
427
- <tr><td>kb shortcut</td> <td>DevTools-adjacent keyboard shortcut used.</td></tr>
428
- <tr><td>layout shift</td> <td>Viewport layout changed substantially.</td></tr>
429
- <tr><td>zoom change</td> <td>Browser zoom level changed.</td></tr>
430
- <tr><td>synthetic insertion</td><td>Text appeared without matching keystrokes.</td></tr>
431
- <tr><td>foreign input</td> <td>Keystroke outside the response field.</td></tr>
432
- <tr><td>edge exit</td> <td>Mouse exited the viewport edge &mdash; pattern often seen with sidebar use.</td></tr>
433
- </tbody>
434
- </table>
435
- </div>
436
- </div>
437
- <div id="lightbox" class="lightbox-overlay" role="dialog" aria-label="Enlarged image">
438
- <span class="lightbox-close" aria-label="Close">&times;</span>
439
- <img id="lightbox-img" alt="">
440
- </div>
441
-
442
- <script>
443
- // Tasks 4-7 will wire interactivity (cohort selection, lightbox, legend modal).
444
- (function() {
445
- // DOM refs — captured once on load. Cohort rows and detail panes were emitted
446
- // server-side, so these collections stay valid for the page's lifetime.
447
- const rail = document.querySelector('.rail');
448
- const detail = document.querySelector('.detail');
449
- const rows = [...document.querySelectorAll('.cohort-row')];
450
- const panes = [...document.querySelectorAll('.participant')];
451
-
452
- // --- Modal & lightbox refs (Task 11) ---
453
- const legend = document.getElementById('legend-modal');
454
- const legendBtn = document.querySelector('.legend-btn');
455
- const legendClose = legend.querySelector('.modal-close');
456
- const legendBack = legend.querySelector('.modal-backdrop');
457
- const overlay = document.getElementById('lightbox');
458
- const overlayImg = document.getElementById('lightbox-img');
459
-
460
- // Module-scoped current selection, indexed by participantId. Tasks 10/11 read it.
461
- let currentId = null;
462
-
463
- function isVisible(row) {
464
- // CSS hides via either:
465
- // - data-filter rule (display: none from body[data-filter] not matching tier)
466
- // - .search-hidden class (display: none from CSS)
467
- // Both resolve to display: none, so getComputedStyle catches the union.
468
- return !row.classList.contains('search-hidden')
469
- && getComputedStyle(row).display !== 'none';
470
- }
471
-
472
- function reconcileSelection() {
473
- const visible = rows.filter(isVisible);
474
- if (visible.length === 0) {
475
- // No matches — clear panes and show empty-state hint in the detail column.
476
- for (const p of panes) p.setAttribute('hidden', '');
477
- for (const r of rows) r.classList.remove('selected');
478
- detail.dataset.empty = 'true';
479
- currentId = null;
480
- return;
481
- }
482
- delete detail.dataset.empty;
483
- // Keep current selection if still visible; otherwise pick the top visible row.
484
- const stillVisible = visible.find(r => r.dataset.pid === currentId);
485
- if (!stillVisible) selectById(visible[0].dataset.pid);
486
- }
487
-
488
- function selectById(pid, opts = {}) {
489
- const row = rows.find(r => r.dataset.pid === pid);
490
- if (!row) return;
491
- const sanitized = row.dataset.sanitized;
492
-
493
- // Toggle [hidden] on detail panes — only one visible at a time.
494
- for (const p of panes) {
495
- if (p.id === \`p-\${sanitized}\`) p.removeAttribute('hidden');
496
- else p.setAttribute('hidden', '');
497
- }
498
-
499
- // Update .selected on rows.
500
- for (const r of rows) r.classList.toggle('selected', r === row);
501
-
502
- currentId = pid;
503
- detail.scrollTop = 0;
504
-
505
- // Persist selection in the URL hash so a reload restores the same participant.
506
- // Tasks that mutate visibility (filter/search) reuse selectById so the hash
507
- // stays consistent with the visible pane.
508
- if (!opts.skipHash) {
509
- history.replaceState(null, '', \`#p-\${sanitized}\`);
510
- }
511
- }
512
-
513
- // --- Overlay open/close helpers (Task 11) ---
514
- function openLegend() { legend.removeAttribute('hidden'); }
515
- function closeLegend() { legend.setAttribute('hidden', ''); }
516
- function closeLightbox() { overlay.classList.remove('open'); overlayImg.src = ''; }
517
-
518
- // --- Legend modal wiring ---
519
- legendBtn.addEventListener('click', openLegend);
520
- legendClose.addEventListener('click', closeLegend);
521
- legendBack.addEventListener('click', closeLegend);
522
-
523
- // --- Lightbox wiring ---
524
- // Delegate via existing a.zoomable elements. They're emitted server-side
525
- // around each image, so a fresh querySelectorAll at IIFE init time is fine.
526
- document.querySelectorAll('a.zoomable').forEach(a => {
527
- a.addEventListener('click', e => {
528
- e.preventDefault();
529
- overlayImg.src = a.getAttribute('href');
530
- overlay.classList.add('open');
531
- });
532
- });
533
- overlay.addEventListener('click', closeLightbox);
534
-
535
- // --- Paste-toggle wiring ---
536
- // Delegated handler for paste-toggle buttons. Only "long" pastes have a
537
- // .paste-full element; short pastes have a disabled toggle and we exit early.
538
- document.addEventListener('click', e => {
539
- const btn = e.target.closest('.paste-toggle');
540
- if (!btn || btn.disabled) return;
541
- const entry = btn.closest('.paste-entry');
542
- if (!entry || !entry.querySelector('.paste-full')) return;
543
- const expanded = entry.classList.toggle('expanded');
544
- btn.textContent = expanded ? '▾' : '▸';
545
- btn.setAttribute('aria-expanded', String(expanded));
546
- });
547
-
548
- // Delegated click handler — one listener on the rail catches all row clicks.
549
- rail.addEventListener('click', e => {
550
- const row = e.target.closest('.cohort-row');
551
- if (row) selectById(row.dataset.pid);
552
- });
553
-
554
- // Initial selection on page load. Priority:
555
- // 1. URL hash matches a participant — restore it.
556
- // 2. Default to first row (most-suspicious, since triage is sorted desc).
557
- // 3. No rows at all — do nothing (defensive).
558
- (function initSelection() {
559
- const match = location.hash.match(/^#p-(.+)$/);
560
- if (match) {
561
- const row = rows.find(r => r.dataset.sanitized === match[1]);
562
- if (row) {
563
- selectById(row.dataset.pid, { skipHash: true });
564
- return;
565
- }
566
- }
567
- if (rows[0]) selectById(rows[0].dataset.pid, { skipHash: true });
568
- })();
569
-
570
- // --- Filter chips ---
571
- // CSS does the actual hiding via body[data-filter] selectors. JS just sets
572
- // the data attribute and updates which chip is .active.
573
- const chips = [...document.querySelectorAll('.filter-chip')];
574
- chips.forEach(chip => {
575
- chip.addEventListener('click', () => {
576
- document.body.dataset.filter = chip.dataset.filter;
577
- chips.forEach(c => c.classList.toggle('active', c === chip));
578
- reconcileSelection();
579
- });
580
- });
581
-
582
- // --- Search ---
583
- const searchEl = document.querySelector('.search-wrap input');
584
- searchEl.addEventListener('input', () => {
585
- const q = searchEl.value.trim().toLowerCase();
586
- for (const r of rows) {
587
- // data-pid is mixed case; data-reason is pre-lowercased server-side (Task 3).
588
- const hit = !q
589
- || r.dataset.pid.toLowerCase().includes(q)
590
- || r.dataset.reason.includes(q);
591
- r.classList.toggle('search-hidden', !hit);
592
- }
593
- reconcileSelection();
594
- });
595
-
596
- // --- Sort ---
597
- const sortEl = document.querySelector('.sort-wrap select');
598
- sortEl.addEventListener('change', () => {
599
- const key = sortEl.value; // 'score' | 'id' | 'tier'
600
- const tierRank = { hard: 0, soft: 1, clean: 2 };
601
- const parent = rows[0]?.parentNode;
602
- if (!parent) return;
603
- const sorted = [...rows].sort((a, b) => {
604
- if (key === 'score') return parseFloat(b.dataset.score) - parseFloat(a.dataset.score);
605
- if (key === 'id') return a.dataset.pid.localeCompare(b.dataset.pid);
606
- // 'tier': hard first, then soft, then clean; within tier, score desc.
607
- const dt = tierRank[a.dataset.tier] - tierRank[b.dataset.tier];
608
- if (dt !== 0) return dt;
609
- return parseFloat(b.dataset.score) - parseFloat(a.dataset.score);
610
- });
611
- for (const r of sorted) parent.appendChild(r);
612
- // Sort doesn't change visibility, but the call is cheap and protects against
613
- // future filter+sort interactions from getting out of sync.
614
- reconcileSelection();
615
- });
616
-
617
- // --- Keyboard nav ---
618
- // Single document.keydown listener handles ESC (close overlays), '/' (focus
619
- // search), and arrow nav. Keeping it as one listener avoids cross-browser
620
- // ordering races between two registered listeners.
621
- document.addEventListener('keydown', e => {
622
- // ESC closes whichever overlay is open. Lightbox first, then legend modal.
623
- // Returning here prevents arrow nav from also firing when the user is just
624
- // dismissing an overlay.
625
- if (e.key === 'Escape') {
626
- if (overlay.classList.contains('open')) { closeLightbox(); return; }
627
- if (!legend.hasAttribute('hidden')) { closeLegend(); return; }
628
- }
629
-
630
- const inInput = e.target && /^(INPUT|TEXTAREA)$/.test(e.target.tagName);
631
-
632
- if (e.key === '/' && !inInput) {
633
- e.preventDefault();
634
- searchEl.focus();
635
- return;
636
- }
637
-
638
- if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && !inInput) {
639
- const visible = rows.filter(isVisible);
640
- if (visible.length === 0) return;
641
-
642
- const currentIdx = visible.findIndex(r => r.classList.contains('selected'));
7
+ import { renderIndexHtml } from './html-index-core.js';
643
8
 
644
- // No current selection within the visible set (e.g. selection was filtered
645
- // out and reconciliation hasn't run, or URL hash didn't match anything).
646
- // Pick the first visible row deterministically rather than relying on
647
- // Math.max(0, -1 - 1) clamping.
648
- if (currentIdx === -1) {
649
- e.preventDefault();
650
- visible[0].scrollIntoView({ block: 'nearest' });
651
- selectById(visible[0].dataset.pid);
652
- return;
653
- }
654
-
655
- const nextIdx = e.key === 'ArrowDown'
656
- ? Math.min(visible.length - 1, currentIdx + 1)
657
- : Math.max(0, currentIdx - 1);
658
- if (nextIdx !== currentIdx) {
659
- e.preventDefault();
660
- const target = visible[nextIdx];
661
- target.scrollIntoView({ block: 'nearest' });
662
- selectById(target.dataset.pid);
663
- }
664
- }
665
- });
666
- })();
667
- </script>
668
- </body>
669
- </html>`;
9
+ // The replay viewer client is developed as a real JS file (linted, syntax-
10
+ // highlighted) and embedded verbatim at render time — same file://-safe
11
+ // output as the inline IIFE, without string-blob development pain.
12
+ const REPLAY_CLIENT_SRC = readFileSync(
13
+ new URL('./replay-viewer.client.js', import.meta.url), 'utf8');
670
14
 
15
+ export async function renderHtmlIndex(summaries, triage, participants, config, visualsRendered) {
16
+ const html = await renderIndexHtml(summaries, triage, participants, config,
17
+ visualsRendered, { replayClientSrc: REPLAY_CLIENT_SRC });
671
18
  writeFileSync(join(config.outputDir, 'index.html'), html);
672
19
  console.log(' index.html — report page');
673
20
  }
674
-
675
- // Tier classification used by the rail rows, totals, and (later) the detail pane.
676
- // Hard takes precedence over soft; anything else is clean.
677
- function tierOf(t) {
678
- return t.hardTriggered ? 'hard' : t.softFlagged ? 'soft' : 'clean';
679
- }
680
-
681
- // Canonical signal taxonomy for the detail-pane "signal grid" section.
682
- // Order matters — render in this order. `tone` controls colour when value > 0:
683
- // critical — signals that can hard-flag a participant on their own (paste,
684
- // drop, long tab-aways, AI extensions detected).
685
- // warn — soft-score-contributing signals that compound with others.
686
- // muted — diagnostic signals that are noise alone but corroborate.
687
- // Tone classes track scoring weights; if computeTriageScore changes, audit this.
688
- // `aiExtensionsCount` and `edgeExitCount` are derived (not on summary directly);
689
- // renderSignalGrid handles that mapping.
690
- const SIGNALS = [
691
- { key: 'totalPasteEvents', label: 'Paste', tone: 'critical', hint: 'Clipboard paste events into the input' },
692
- { key: 'totalCopyEvents', label: 'Copy', tone: 'warn', hint: 'Clipboard copy events from the task' },
693
- { key: 'totalDropEvents', label: 'Drop', tone: 'critical', hint: 'Drag-and-drop into inputs' },
694
- { key: 'tabAwayLongCount', label: 'Tab-away ≥10s', tone: 'critical', hint: 'Window blurred for 10+ seconds' },
695
- { key: 'tabAwayMediumCount', label: 'Tab-away 3–10s', tone: 'warn', hint: 'Short tab-aways — often a peek' },
696
- { key: 'tabAwayFlickerCount', label: 'Flicker <3s', tone: 'muted', hint: 'Very short visibility flickers' },
697
- { key: 'trialsWithFastTyping', label: 'Fast typing', tone: 'warn', hint: 'Trials typed faster than the preset cps threshold' },
698
- { key: 'totalSyntheticInsertions', label: 'Synthetic', tone: 'warn', hint: 'Text appeared without preceding keystrokes' },
699
- { key: 'totalForeignInputEvents', label: 'Foreign input', tone: 'warn', hint: 'Typing landed outside the experiment container' },
700
- { key: 'sidebarEventCount', label: 'Sidebar', tone: 'warn', hint: 'innerWidth shrank — side panel opened' },
701
- { key: 'aiExtensionsCount', label: 'AI extensions', tone: 'critical', hint: 'Known AI extension selectors found in DOM' },
702
- { key: 'keyboardShortcutCount', label: 'Kb shortcuts', tone: 'warn', hint: 'Cmd/Ctrl-C/V / AI-specific keys' },
703
- { key: 'layoutShiftCount', label: 'Layout shifts', tone: 'muted', hint: 'Inferred layout compression events' },
704
- { key: 'zoomChangeCount', label: 'Zoom changes', tone: 'muted', hint: 'Browser zoom level changed during task' },
705
- { key: 'devToolsEventCount', label: 'DevTools', tone: 'muted', hint: 'DevTools opened or related shortcut' },
706
- { key: 'edgeExitCount', label: 'Edge exits', tone: 'muted', hint: 'Mouse exited window through a screen edge' }
707
- ];
708
-
709
- // Renders all 16 SIGNALS as a colour-coded tile grid. Every signal renders
710
- // (hits AND misses) so the layout is stable across participants — only firing
711
- // signals light up. Misses share the same footprint as hits, in the muted
712
- // "tone-zero" style. Cleans show all-zero, suspicious shows a few lit cells.
713
- function renderSignalGrid(summary, triageRow) {
714
- const aiExt = (summary.aiExtensionsFound || summary.extensionsDetected || []).length;
715
- const edgeExits = triageRow?.edgeExitCount ?? 0;
716
-
717
- const tiles = SIGNALS.map(sig => {
718
- let v;
719
- if (sig.key === 'aiExtensionsCount') v = aiExt;
720
- else if (sig.key === 'edgeExitCount') v = edgeExits;
721
- else v = summary[sig.key] ?? 0;
722
- return { ...sig, value: Number(v) || 0 };
723
- });
724
-
725
- return `<div class="signal-grid" role="list">
726
- ${tiles.map(t => `
727
- <div class="signal-tile ${t.value > 0 ? 'tone-' + t.tone : 'tone-zero'}"
728
- role="listitem"
729
- title="${esc(t.hint)}">
730
- <span class="signal-value">${t.value}</span>
731
- <span class="signal-label">${esc(t.label)}</span>
732
- </div>
733
- `).join('')}
734
- </div>`;
735
- }
736
-
737
- // Build the entire left rail HTML: top band (search/filters/sort), scrolling
738
- // list of cohort rows, and the sticky cohort totals footer. Rendering is
739
- // pure HTML — no client-side handlers here; Tasks 9–10 wire up interactivity.
740
- function renderCohortList(triage, cohortCounts) {
741
- const rowsHtml = triage.map(renderCohortRow).join('');
742
-
743
- return `<div class="rail-top">
744
- <div class="search-wrap">
745
- <input type="search" placeholder="Search (press / to focus)" aria-label="Search participants">
746
- </div>
747
- <div class="filter-chips">
748
- <button class="filter-chip active" data-filter="all">All ${triage.length}</button>
749
- <button class="filter-chip" data-filter="hard">Hard ${cohortCounts.hard}</button>
750
- <button class="filter-chip" data-filter="soft">Soft ${cohortCounts.soft}</button>
751
- <button class="filter-chip" data-filter="clean">Clean ${cohortCounts.clean}</button>
752
- </div>
753
- <div class="sort-wrap">
754
- <label>Sort:
755
- <select name="sort">
756
- <option value="score">Score &darr;</option>
757
- <option value="id">ID</option>
758
- <option value="tier">Tier</option>
759
- </select>
760
- </label>
761
- </div>
762
- </div>
763
-
764
- <div class="rail-list">
765
- ${rowsHtml}
766
- </div>
767
-
768
- <div class="rail-footer">
769
- <div class="totals-title">Cohort totals</div>
770
- <div class="totals-row" data-tier="hard"><span class="tier-dot" data-tier="hard"></span> Hard <span class="mono count">${cohortCounts.hard}</span></div>
771
- <div class="totals-row" data-tier="soft"><span class="tier-dot" data-tier="soft"></span> Soft <span class="mono count">${cohortCounts.soft}</span></div>
772
- <div class="totals-row" data-tier="clean"><span class="tier-dot" data-tier="clean"></span> Clean <span class="mono count">${cohortCounts.clean}</span></div>
773
- </div>`;
774
- }
775
-
776
- // Single cohort row. The data-* attributes are the contract Tasks 9–10 read
777
- // from the client-side JS to filter/sort/search — don't omit any.
778
- // data-pid: full participant id (raw, esc'd for HTML)
779
- // data-sanitized: filename-safe id (used to look up image filenames)
780
- // data-tier: 'hard' | 'soft' | 'clean'
781
- // data-score: numeric score (defaulted to 0 if null/undefined)
782
- // data-reason: pre-lowercased reason string (search reads this so the
783
- // handler doesn't need to lowercase per keystroke)
784
- function renderCohortRow(t) {
785
- const tier = tierOf(t);
786
- const pid = String(t.participantId || '');
787
- const sanitized = sanitize(pid);
788
- const score = t.score ?? 0;
789
- // triage.js → generateTriageReason returns 'clean' for clean rows, but real
790
- // data may slip an empty string through; normalise to 'clean' for display.
791
- const reason = String(t.reason || 'clean');
792
- const reasonLower = reason.toLowerCase();
793
- return `<div class="cohort-row" data-pid="${esc(pid)}"
794
- data-sanitized="${esc(sanitized)}"
795
- data-tier="${tier}"
796
- data-score="${score}"
797
- data-reason="${esc(reasonLower)}">
798
- <div class="cohort-row-top">
799
- <span class="tier-dot" data-tier="${tier}"></span>
800
- <span class="mono pid" title="${esc(pid)}">${esc(pid)}</span>
801
- <span class="mono score">${score}</span>
802
- </div>
803
- <div class="cohort-row-bot">
804
- <span class="reason-excerpt">${esc(reason)}</span>
805
- <span class="tier-badge" data-tier="${tier}">${tier === 'clean' ? 'clean' : tier.toUpperCase()}</span>
806
- </div>
807
- </div>`;
808
- }
809
-
810
- function esc(str) {
811
- return String(str || '')
812
- .replace(/&/g, '&amp;')
813
- .replace(/</g, '&lt;')
814
- .replace(/>/g, '&gt;')
815
- .replace(/"/g, '&quot;');
816
- }
817
-
818
- function sanitize(name) {
819
- return String(name || '').replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
820
- }
821
-
822
- // Renders a single participant detail pane. For Task 4 this is just the header
823
- // strip + score breakdown — Tasks 5-7 will append reason pull-quote, session
824
- // signals, paste evidence, and images. The `hidden` attribute is omitted on the
825
- // first pane so the report has a default selection on load; client JS toggles
826
- // `hidden` on the others when the user clicks a different cohort row.
827
- function renderDetail(t, participant, visualsRendered, defaultVisible) {
828
- const sanitized = sanitize(t.participantId);
829
- const tier = tierOf(t);
830
- const s = t.summary || {};
831
-
832
- // Image sections — three .image-block wrappers (tab timeline, typing profile,
833
- // mouse trajectories). The renderers in tab-timeline.js / typing-profile.js /
834
- // trajectories.js skip participants under various conditions, so not every
835
- // participant has every image. The wrapper exists so onerror can hide both
836
- // the heading and the image together — without it, a missing PNG would leave
837
- // an orphan section heading floating above nothing.
838
- let imagesHtml;
839
- if (visualsRendered) {
840
- // Order: typing profile → tab timeline → mouse trajectories. Typing speed
841
- // first because it's the most directly comparable across participants
842
- // (one bar per trial, threshold line).
843
- imagesHtml = `
844
- <div class="image-block">
845
- <h4 class="section-heading">Typing profile</h4>
846
- <a href="images/typing_profile_${sanitized}.png" class="zoomable">
847
- <img src="images/typing_profile_${sanitized}.png" alt="Typing profile"
848
- onerror="this.closest('.image-block').style.display='none'">
849
- </a>
850
- </div>
851
- <div class="image-block">
852
- <h4 class="section-heading">Tab timeline</h4>
853
- <a href="images/tab_timeline_${sanitized}.png" class="zoomable">
854
- <img src="images/tab_timeline_${sanitized}.png" alt="Tab timeline"
855
- onerror="this.closest('.image-block').style.display='none'">
856
- </a>
857
- </div>
858
- <div class="image-block">
859
- <h4 class="section-heading">Mouse trajectories</h4>
860
- <a href="images/trajectories_${sanitized}.png" class="zoomable">
861
- <img src="images/trajectories_${sanitized}.png" alt="Mouse trajectories"
862
- onerror="this.closest('.image-block').style.display='none'">
863
- </a>
864
- </div>`;
865
- } else {
866
- imagesHtml = `<p class="muted note">Visual renderers not available (install the canvas package).</p>`;
867
- }
868
-
869
- return `<section class="participant" id="p-${sanitized}"${defaultVisible ? '' : ' hidden'}>
870
- ${renderDetailHeader(t, tier)}
871
- ${renderSignalGrid(s, t)}
872
- ${renderScoreBreakdown(t)}
873
- ${renderReasonQuote(t)}
874
- ${renderSessionBlock(s, participant)}
875
- ${renderPasteEvidence(participant)}
876
- ${imagesHtml}
877
- </section>`;
878
- }
879
-
880
- // Renders the participant's screenout reason as a left-bordered pull-quote.
881
- // `t.reason` is set by triage.js; falls back to "clean" when absent. Always
882
- // renders a quote (even for clean participants) so the visual rhythm is consistent.
883
- function renderReasonQuote(t) {
884
- return `<blockquote class="reason">${esc(t.reason || 'clean')}</blockquote>`;
885
- }
886
-
887
- // Renders the session-level event-detail block: cells for AI extensions
888
- // (names), sidebar events (signed gap + timestamp + duration), and keyboard
889
- // shortcuts (combos). Counts are NOT shown here — the signal grid above
890
- // already carries the at-a-glance count for every signal type. This block
891
- // adds the precision the grid can't: which specific events fired and what
892
- // they looked like. Pure-count signals (layout shifts, zoom changes, devtools)
893
- // are not rendered here because they have no per-event detail to add.
894
- //
895
- // Returns '' when no detail-bearing signals fired, so clean panes don't show
896
- // an empty "Session-level signals" heading.
897
- function renderSessionBlock(s, participant) {
898
- const aiExt = s.aiExtensionsFound || [];
899
- const sidebar = participant?.session?.sidebarEvents || [];
900
- const kb = participant?.session?.keyboardShortcuts || [];
901
-
902
- if (!aiExt.length && !sidebar.length && !kb.length) {
903
- return '';
904
- }
905
-
906
- const cells = [];
907
- if (aiExt.length) {
908
- // AI extensions can be strings or {name: '...'} objects (legacy convention).
909
- const lines = aiExt.slice(0, 3).map(e => esc(typeof e === 'string' ? e : (e?.name || 'unknown')));
910
- cells.push(cellHtml('AI extensions', aiExt.length, lines));
911
- }
912
- if (sidebar.length) {
913
- cells.push(cellHtml('Sidebar events', sidebar.length, sidebar.slice(0, 3).map(formatSidebar)));
914
- }
915
- if (kb.length) {
916
- cells.push(cellHtml('Kb shortcuts', kb.length, kb.slice(0, 3).map(ev =>
917
- esc(String(ev?.combo || ev?.key || 'unknown')))));
918
- }
919
-
920
- return `
921
- <h4 class="section-heading">Session-level signals</h4>
922
- <div class="session-grid">${cells.join('')}</div>
923
- `;
924
- }
925
-
926
- // One signal cell: uppercase title + up to 3 preview lines + an overflow line
927
- // when the underlying count exceeds previewLines.length. Caller is responsible
928
- // for esc'ing entries in previewLines (we trust the caller here so callers can
929
- // embed safe markup like the muted span in formatSidebar).
930
- function cellHtml(title, count, previewLines) {
931
- const overflow = count > previewLines.length
932
- ? `<li class="muted">… +${count - previewLines.length} more</li>`
933
- : '';
934
- return `<div class="sig-cell">
935
- <div class="sig-cell-title">${title}</div>
936
- <ul>${previewLines.map(l => `<li>${l}</li>`).join('')}${overflow}</ul>
937
- </div>`;
938
- }
939
-
940
- // Format one sidebar event row, e.g. "+312px · 00:12" or "−308px · 00:45 (33.0s open)".
941
- // `deltaIW` is the signed innerWidth change at the event boundary — positive on
942
- // open (viewport shrank), negative on close. Falls back to `gap` / `gapPx` for
943
- // older fixtures. Timestamp is rendered as MM:SS from start-of-trial.
944
- function formatSidebar(ev) {
945
- const gap = ev?.deltaIW ?? ev?.gap ?? ev?.gapPx ?? '?';
946
- const gapStr = typeof gap === 'number' ? (gap > 0 ? `+${gap}` : String(gap)) : String(gap);
947
- const ts = ev?.t ?? ev?.timestamp ?? 0;
948
- const mm = Math.floor(ts / 60000).toString().padStart(2, '0');
949
- const ss = Math.floor((ts % 60000) / 1000).toString().padStart(2, '0');
950
- // Only "closed" events carry duration_ms; render it as the trailing parenthetical.
951
- const dur = (ev?.type === 'closed' && ev?.duration_ms != null)
952
- ? ` <span class="muted">(${(ev.duration_ms / 1000).toFixed(1)}s open)</span>`
953
- : '';
954
- return `${esc(gapStr)}px · ${mm}:${ss}${dur}`;
955
- }
956
-
957
- // Header strip: full participant id (mono), tier pill, big score number on the
958
- // top row; "{n} trials · {cps} cps · {off-task}" subline below.
959
- function renderDetailHeader(t, tier) {
960
- const s = t.summary || {};
961
- const cps = (s.meanTypingSpeed || 0).toFixed(1);
962
- const offTask = formatDuration(s.totalTabAwayDuration_ms || 0);
963
- const tierLabel = tier === 'clean' ? 'clean' : tier.toUpperCase();
964
- return `<div class="detail-header">
965
- <div class="detail-header-top">
966
- <span class="mono pid-full">${esc(t.participantId)}</span>
967
- <span class="tier-pill" data-tier="${tier}">${tierLabel}</span>
968
- <span class="mono score-big">${t.score}</span>
969
- </div>
970
- <div class="detail-header-sub">
971
- ${s.trialCount ?? 0} trials · ${cps} cps · ${offTask} off-task
972
- </div>
973
- </div>`;
974
- }
975
-
976
- // Horizontal score breakdown — only non-zero contributions, ending in Total:N.
977
- // Sourced from decomposeScore so the displayed terms always sum to t.score.
978
- function renderScoreBreakdown(t) {
979
- const terms = decomposeScore(t.summary || {}, t.edgeExitCount, t.hardTriggered)
980
- .filter(([, n]) => n > 0);
981
-
982
- const total = t.score;
983
- const maxBar = 100; // px
984
- const barFor = n => Math.max(2, Math.round((n / Math.max(total, 1)) * maxBar));
985
-
986
- const termHtml = terms.map(([label, n]) =>
987
- `<span class="score-term">
988
- <span class="label">${label}</span>
989
- <span class="mono contrib">+${n}</span>
990
- <span class="bar" style="width:${barFor(n)}px"></span>
991
- </span>`
992
- ).join('');
993
-
994
- return `<div class="score-breakdown">
995
- ${termHtml}
996
- <span class="score-total mono">Total: ${total}</span>
997
- </div>`;
998
- }
999
-
1000
- // Format milliseconds as "Xs" / "Xm Ys".
1001
- function formatDuration(ms) {
1002
- if (!ms || ms < 1000) return '0s';
1003
- const s = Math.round(ms / 1000);
1004
- if (s < 60) return `${s}s`;
1005
- return `${Math.floor(s / 60)}m ${s % 60}s`;
1006
- }
1007
-
1008
- // Renders a "Paste evidence (N)" section listing actual pasted texts grouped by
1009
- // trial. Pulls from participant.trials[*].pasteEvents — same source the CSV
1010
- // renderer uses. Returns '' when there are no pastes so we don't emit an empty
1011
- // heading. Long pastes are previewed with an ellipsis and a ▸ toggle that
1012
- // Task 11's client JS will wire up to swap the preview for the full text.
1013
- function renderPasteEvidence(participant) {
1014
- // Flatten across trials, keeping [trialId] context for each paste. Match the
1015
- // event-log.csv convention: prefer trialId, fall back to ruleId (rule-gallery
1016
- // legacy data), then to '?' as a last resort. Paste text is coerced to string
1017
- // because some malformed sources have non-string values.
1018
- const pastes = [];
1019
- for (const trial of (participant?.trials || [])) {
1020
- const id = trial?.trialId || trial?.ruleId || '?';
1021
- for (const ev of (trial?.pasteEvents || [])) {
1022
- pastes.push({ trialId: id, text: String(ev?.text ?? '') });
1023
- }
1024
- }
1025
- if (pastes.length === 0) return '';
1026
-
1027
- const PREVIEW_LEN = 150;
1028
- const MAX = 10;
1029
- const shown = pastes.slice(0, MAX);
1030
-
1031
- const items = shown.map(p => {
1032
- const isLong = p.text.length > PREVIEW_LEN;
1033
- const preview = isLong ? p.text.slice(0, PREVIEW_LEN) + '…' : p.text;
1034
- // Glyph: ▸ (collapsed) toggles to ▾ (expanded) via Task 11's JS.
1035
- // For short pastes we render a static · marker — nothing to expand.
1036
- const toggleGlyph = isLong ? '▸' : '·';
1037
- const toggleAttr = isLong ? '' : ' disabled aria-label="No expansion needed"';
1038
- return `<div class="paste-entry">
1039
- <button class="paste-toggle" type="button" aria-expanded="false"${toggleAttr}>${toggleGlyph}</button>
1040
- <span class="mono paste-trial">[${esc(p.trialId)}]</span>
1041
- <span class="paste-preview mono">${esc(preview)}</span>
1042
- ${isLong ? `<span class="paste-full mono" hidden>${esc(p.text)}</span>` : ''}
1043
- </div>`;
1044
- }).join('');
1045
-
1046
- const overflow = pastes.length > MAX
1047
- ? `<div class="muted paste-overflow">… and ${pastes.length - MAX} more; see event-log.csv for the full list.</div>`
1048
- : '';
1049
-
1050
- return `
1051
- <h4 class="section-heading">Paste evidence (${pastes.length})</h4>
1052
- <div class="paste-list">${items}${overflow}</div>
1053
- `;
1054
- }