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