cyborg-hunter 0.7.0 → 0.7.3

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