cyborg-hunter 0.3.0
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.
- package/README.md +145 -0
- package/bin/cyborg-hunter.js +20 -0
- package/dist/cyborg-hunter.esm.js +1534 -0
- package/dist/cyborg-hunter.min.js +6 -0
- package/dist/jspsych-cyborg-hunter.js +1 -0
- package/package.json +56 -0
- package/src/cli/analyzers/edge-exit.js +56 -0
- package/src/cli/analyzers/summary.js +99 -0
- package/src/cli/analyzers/triage.js +104 -0
- package/src/cli/config.js +99 -0
- package/src/cli/ingest.js +343 -0
- package/src/cli/init.js +31 -0
- package/src/cli/renderers/event-log.js +66 -0
- package/src/cli/renderers/extensions.js +43 -0
- package/src/cli/renderers/html-index.js +329 -0
- package/src/cli/renderers/summary-csv.js +70 -0
- package/src/cli/renderers/tab-timeline.js +149 -0
- package/src/cli/renderers/trajectories.js +607 -0
- package/src/cli/renderers/triage-md.js +29 -0
- package/src/cli/renderers/typing-profile.js +200 -0
- package/src/cli/report.js +100 -0
- package/src/core/index.js +13 -0
- package/src/core/monitor.js +404 -0
- package/src/core/scoring.js +153 -0
- package/src/core/signals/browser.js +303 -0
- package/src/core/signals/clipboard.js +91 -0
- package/src/core/signals/dom-protection.js +285 -0
- package/src/core/signals/focus.js +101 -0
- package/src/core/signals/mouse.js +117 -0
- package/src/core/signals/typing.js +110 -0
- package/src/core/state-machine.js +88 -0
- package/src/jspsych/extension.js +199 -0
- package/src/shared/constants.js +162 -0
- package/src/shared/schema.js +56 -0
- package/src/shared/validation.js +73 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// src/cli/renderers/html-index.js
|
|
2
|
+
// Generates the self-contained HTML report page.
|
|
3
|
+
// References linked images in images/ (not base64-embedded).
|
|
4
|
+
//
|
|
5
|
+
// Sections:
|
|
6
|
+
// - Dashboard header with counts
|
|
7
|
+
// - Sortable triage table (vanilla JS, no dependencies)
|
|
8
|
+
// - Per-participant detail sections with image references
|
|
9
|
+
// - Inline CSS for clean layout
|
|
10
|
+
// - Works offline when opened from the output folder
|
|
11
|
+
|
|
12
|
+
import { writeFileSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import { VERSION } from '../../shared/constants.js';
|
|
15
|
+
|
|
16
|
+
export async function renderHtmlIndex(summaries, triage, participants, config, visualsRendered) {
|
|
17
|
+
const flaggedHard = triage.filter(t => t.hardTriggered).length;
|
|
18
|
+
const flaggedSoft = triage.filter(t => !t.hardTriggered && t.softFlagged).length;
|
|
19
|
+
const clean = triage.length - flaggedHard - flaggedSoft;
|
|
20
|
+
|
|
21
|
+
const triageRows = triage.map((t, i) => {
|
|
22
|
+
const hardClass = t.hardTriggered ? 'hard' : t.softFlagged ? 'soft' : 'clean';
|
|
23
|
+
const s = t.summary || {};
|
|
24
|
+
const extCount = (s.aiExtensionsFound || []).length;
|
|
25
|
+
// Soft score from the configured scoring engine. Prefer session-authoritative
|
|
26
|
+
// (set by the monitor at collection time using the active scoring config);
|
|
27
|
+
// fall back to the per-trial accumulated sum when the session didn't record one.
|
|
28
|
+
const softScore = s.authoritativeSoftScore ?? s.totalSoftScore ?? 0;
|
|
29
|
+
return `<tr class="${hardClass}" onclick="scrollToParticipant('${esc(t.participantId)}')">
|
|
30
|
+
<td>${i + 1}</td>
|
|
31
|
+
<td>${esc(t.participantId)}</td>
|
|
32
|
+
<td>${t.score}</td>
|
|
33
|
+
<td>${softScore}</td>
|
|
34
|
+
<td>${t.hardTriggered ? '<strong>YES</strong>' : 'no'}</td>
|
|
35
|
+
<td class="session-col">${extCount}</td>
|
|
36
|
+
<td class="session-col">${s.sidebarEventCount ?? 0}</td>
|
|
37
|
+
<td class="session-col">${s.keyboardShortcutCount ?? 0}</td>
|
|
38
|
+
<td class="session-col">${(s.layoutShiftCount ?? 0) + (s.zoomChangeCount ?? 0)}</td>
|
|
39
|
+
<td>${esc(t.reason)}</td>
|
|
40
|
+
</tr>`;
|
|
41
|
+
}).join('\n');
|
|
42
|
+
|
|
43
|
+
const participantSections = triage.map(t => {
|
|
44
|
+
const s = t.summary;
|
|
45
|
+
const pid = sanitize(t.participantId);
|
|
46
|
+
const pidEsc = esc(t.participantId);
|
|
47
|
+
|
|
48
|
+
let images = '';
|
|
49
|
+
if (visualsRendered) {
|
|
50
|
+
// P4: Images are wrapped in .zoomable so the lightbox JS can hoist them
|
|
51
|
+
// to a full-viewport overlay on click. The bare PNG is still reachable
|
|
52
|
+
// via its href as a graceful fallback if JS is disabled.
|
|
53
|
+
images = `
|
|
54
|
+
<div class="images">
|
|
55
|
+
<a href="images/typing_profile_${pid}.png" class="zoomable" title="Click to enlarge"><img src="images/typing_profile_${pid}.png" alt="Typing profile" onerror="this.parentElement.style.display='none'"></a>
|
|
56
|
+
<a href="images/tab_timeline_${pid}.png" class="zoomable" title="Click to enlarge"><img src="images/tab_timeline_${pid}.png" alt="Tab timeline" onerror="this.parentElement.style.display='none'"></a>
|
|
57
|
+
<a href="images/trajectories_${pid}.png" class="zoomable" title="Click to enlarge"><img src="images/trajectories_${pid}.png" alt="Mouse trajectories" onerror="this.parentElement.style.display='none'"></a>
|
|
58
|
+
</div>`;
|
|
59
|
+
} else {
|
|
60
|
+
images = '<p class="note">Visual renderers not available (install canvas package).</p>';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Session-level signal block — surfaces integrity-monitor session metadata
|
|
64
|
+
// (extensions, sidebars with timestamps, keyboard shortcuts). Only rendered
|
|
65
|
+
// when the session had at least one such event.
|
|
66
|
+
const aiExtensions = s.aiExtensionsFound || [];
|
|
67
|
+
const sidebarEvents = (participants.find(p => p.participantId === t.participantId)?.session?.sidebarEvents) || [];
|
|
68
|
+
const keyboardShortcuts = (participants.find(p => p.participantId === t.participantId)?.session?.keyboardShortcuts) || [];
|
|
69
|
+
const hasSessionSignals = aiExtensions.length > 0 || sidebarEvents.length > 0 || keyboardShortcuts.length > 0 ||
|
|
70
|
+
(s.layoutShiftCount || 0) > 0 || (s.zoomChangeCount || 0) > 0 || (s.devToolsEventCount || 0) > 0;
|
|
71
|
+
|
|
72
|
+
let sessionBlock = '';
|
|
73
|
+
if (hasSessionSignals) {
|
|
74
|
+
const extLines = aiExtensions.map(e => {
|
|
75
|
+
const name = typeof e === 'string' ? e : (e.name || 'unknown');
|
|
76
|
+
const detail = typeof e === 'object' && e.matchedSelector ? ` <span class="muted">(${esc(e.matchedSelector)})</span>` : '';
|
|
77
|
+
return `<li>${esc(name)}${detail}</li>`;
|
|
78
|
+
}).join('');
|
|
79
|
+
const sidebarLines = sidebarEvents.slice(0, 10).map(ev => {
|
|
80
|
+
const ts = ev.t || ev.timestamp || '';
|
|
81
|
+
// Both open and close events now carry a signed `deltaIW` (Task P6).
|
|
82
|
+
// Open emits positive shrinkage (+312px), close emits negative expansion
|
|
83
|
+
// (-312px). Render both with the same "gap" line; the sign tells the
|
|
84
|
+
// story. Duration is appended for close events so triage sees how long
|
|
85
|
+
// the sidebar was visible.
|
|
86
|
+
const gap = ev.deltaIW ?? ev.gap ?? ev.gapPx ?? ev.sidebarGapPx ?? '?';
|
|
87
|
+
const gapStr = typeof gap === 'number' ? (gap > 0 ? `+${gap}` : String(gap)) : String(gap);
|
|
88
|
+
const durSuffix = (ev.type === 'closed' && ev.duration_ms != null)
|
|
89
|
+
? ` <span class="muted">(${(ev.duration_ms / 1000).toFixed(1)}s open)</span>` : '';
|
|
90
|
+
return `<li><code>${esc(String(ts))}</code> — gap ${esc(gapStr)}px${durSuffix}</li>`;
|
|
91
|
+
}).join('');
|
|
92
|
+
const kbLines = keyboardShortcuts.slice(0, 10).map(ev => {
|
|
93
|
+
const key = ev.combo || ev.key || JSON.stringify(ev);
|
|
94
|
+
return `<li><code>${esc(String(key))}</code></li>`;
|
|
95
|
+
}).join('');
|
|
96
|
+
sessionBlock = `
|
|
97
|
+
<div class="session-signals">
|
|
98
|
+
<h4>Session-level signals</h4>
|
|
99
|
+
<div class="session-grid">
|
|
100
|
+
${aiExtensions.length > 0 ? `<div><strong>AI extensions (${aiExtensions.length}):</strong><ul>${extLines}</ul></div>` : ''}
|
|
101
|
+
${sidebarEvents.length > 0 ? `<div><strong>Sidebar events (${sidebarEvents.length}${sidebarEvents.length > 10 ? ', first 10' : ''}):</strong><ul>${sidebarLines}</ul></div>` : ''}
|
|
102
|
+
${keyboardShortcuts.length > 0 ? `<div><strong>Keyboard shortcuts (${keyboardShortcuts.length}${keyboardShortcuts.length > 10 ? ', first 10' : ''}):</strong><ul>${kbLines}</ul></div>` : ''}
|
|
103
|
+
${(s.layoutShiftCount || s.zoomChangeCount || s.devToolsEventCount) ? `<div><strong>Other:</strong><ul>
|
|
104
|
+
${s.layoutShiftCount ? `<li>${s.layoutShiftCount} layout shifts</li>` : ''}
|
|
105
|
+
${s.zoomChangeCount ? `<li>${s.zoomChangeCount} zoom changes</li>` : ''}
|
|
106
|
+
${s.devToolsEventCount ? `<li>${s.devToolsEventCount} devtools events</li>` : ''}
|
|
107
|
+
</ul></div>` : ''}
|
|
108
|
+
</div>
|
|
109
|
+
</div>`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const authScore = s.authoritativeSoftScore != null
|
|
113
|
+
? `<span>Soft score (session): <strong>${s.authoritativeSoftScore}</strong></span>`
|
|
114
|
+
: `<span>Soft score: ${s.totalSoftScore}</span>`;
|
|
115
|
+
|
|
116
|
+
return `
|
|
117
|
+
<section class="participant" id="p-${pid}">
|
|
118
|
+
<h3>${pidEsc}</h3>
|
|
119
|
+
<div class="stats">
|
|
120
|
+
<span>Score: <strong>${t.score}</strong></span>
|
|
121
|
+
<span>Hard: <strong>${t.hardTriggered ? 'YES' : 'no'}</strong></span>
|
|
122
|
+
<span>Trials: ${s.trialCount}</span>
|
|
123
|
+
<span>Paste: ${s.totalPasteEvents}</span>
|
|
124
|
+
<span>Tab-aways: ${formatTabAwayStat(s)}</span>
|
|
125
|
+
<span>Fast typing: ${s.trialsWithFastTyping}</span>
|
|
126
|
+
${authScore}
|
|
127
|
+
<span>Edge exits: ${t.edgeExitCount}</span>
|
|
128
|
+
<span>Extensions: ${aiExtensions.length}</span>
|
|
129
|
+
<span>Sidebar events: ${s.sidebarEventCount ?? 0}</span>
|
|
130
|
+
<span>Kb shortcuts: ${s.keyboardShortcutCount ?? 0}</span>
|
|
131
|
+
</div>
|
|
132
|
+
<p class="reason">${esc(t.reason)}</p>
|
|
133
|
+
${sessionBlock}
|
|
134
|
+
${images}
|
|
135
|
+
</section>`;
|
|
136
|
+
}).join('\n');
|
|
137
|
+
|
|
138
|
+
const html = `<!DOCTYPE html>
|
|
139
|
+
<html lang="en">
|
|
140
|
+
<head>
|
|
141
|
+
<meta charset="utf-8">
|
|
142
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
143
|
+
<title>Cyborg Hunter Report</title>
|
|
144
|
+
<style>
|
|
145
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
146
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
147
|
+
max-width: 1200px; margin: 0 auto; padding: 20px; color: #333; background: #fafafa; }
|
|
148
|
+
h1 { font-size: 24px; margin-bottom: 8px; }
|
|
149
|
+
h2 { font-size: 18px; margin: 24px 0 12px; border-bottom: 1px solid #ddd; padding-bottom: 6px; }
|
|
150
|
+
h3 { font-size: 16px; margin-bottom: 8px; }
|
|
151
|
+
.dashboard { display: flex; gap: 16px; margin: 16px 0; }
|
|
152
|
+
.card { background: white; border: 1px solid #ddd; border-radius: 8px;
|
|
153
|
+
padding: 16px; flex: 1; text-align: center; }
|
|
154
|
+
.card .number { font-size: 32px; font-weight: bold; }
|
|
155
|
+
.card.hard .number { color: #d32f2f; }
|
|
156
|
+
.card.soft .number { color: #f57c00; }
|
|
157
|
+
.card.clean .number { color: #388e3c; }
|
|
158
|
+
table { width: 100%; border-collapse: collapse; margin: 12px 0; background: white; }
|
|
159
|
+
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; }
|
|
160
|
+
th { background: #f5f5f5; cursor: pointer; user-select: none; }
|
|
161
|
+
th:hover { background: #e0e0e0; }
|
|
162
|
+
tr.hard { background: #ffebee; }
|
|
163
|
+
tr.soft { background: #fff3e0; }
|
|
164
|
+
tr.clean { background: #e8f5e9; }
|
|
165
|
+
tr:hover { background: #e3f2fd; cursor: pointer; }
|
|
166
|
+
.participant { background: white; border: 1px solid #ddd; border-radius: 8px;
|
|
167
|
+
padding: 16px; margin: 12px 0; }
|
|
168
|
+
.stats { display: flex; flex-wrap: wrap; gap: 12px; margin: 8px 0; }
|
|
169
|
+
.stats span { background: #f5f5f5; padding: 4px 8px; border-radius: 4px; font-size: 13px; }
|
|
170
|
+
.reason { color: #666; font-size: 13px; margin: 8px 0; }
|
|
171
|
+
.images img { max-width: 100%; height: auto; margin: 8px 0; border: 1px solid #eee; border-radius: 4px; }
|
|
172
|
+
.zoomable { display: block; }
|
|
173
|
+
.zoomable img { cursor: zoom-in; transition: opacity 0.1s; }
|
|
174
|
+
.zoomable:hover img { opacity: 0.9; }
|
|
175
|
+
.lightbox-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 1000;
|
|
176
|
+
display: none; align-items: center; justify-content: center; cursor: zoom-out; padding: 20px; }
|
|
177
|
+
.lightbox-overlay.open { display: flex; }
|
|
178
|
+
.lightbox-overlay img { max-width: 100%; max-height: 100%; box-shadow: 0 4px 24px rgba(0,0,0,0.5); border-radius: 4px; }
|
|
179
|
+
.lightbox-close { position: fixed; top: 16px; right: 24px; color: white; font-size: 32px; cursor: pointer;
|
|
180
|
+
background: rgba(0,0,0,0.4); border-radius: 50%; width: 40px; height: 40px;
|
|
181
|
+
display: flex; align-items: center; justify-content: center; line-height: 1; }
|
|
182
|
+
.note { color: #999; font-style: italic; font-size: 13px; }
|
|
183
|
+
.meta { color: #999; font-size: 12px; margin-top: 24px; }
|
|
184
|
+
.session-col { text-align: center; font-variant-numeric: tabular-nums; }
|
|
185
|
+
th.session-col { background: #eef3ff; }
|
|
186
|
+
.session-signals { background: #fff8e1; border: 1px solid #f0d080; border-radius: 6px;
|
|
187
|
+
padding: 12px; margin: 12px 0; font-size: 13px; }
|
|
188
|
+
.session-signals h4 { font-size: 14px; margin-bottom: 8px; color: #a06000; }
|
|
189
|
+
.session-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
|
190
|
+
.session-signals ul { margin-left: 16px; font-size: 12px; }
|
|
191
|
+
.session-signals code { background: #ffeaaa; padding: 1px 4px; border-radius: 3px; }
|
|
192
|
+
.muted { color: #888; font-size: 12px; }
|
|
193
|
+
.back-to-top { position: fixed; bottom: 16px; right: 16px; padding: 8px 12px; background: #333; color: #fff; border-radius: 4px; text-decoration: none; opacity: 0.85; font-size: 13px; z-index: 100; }
|
|
194
|
+
.back-to-top:hover { opacity: 1; }
|
|
195
|
+
</style>
|
|
196
|
+
</head>
|
|
197
|
+
<body>
|
|
198
|
+
<a id="top"></a>
|
|
199
|
+
<h1>Cyborg Hunter Report</h1>
|
|
200
|
+
<p class="note">v${VERSION} — ${triage.length} participants</p>
|
|
201
|
+
|
|
202
|
+
<div class="dashboard">
|
|
203
|
+
<div class="card hard">
|
|
204
|
+
<div class="number">${flaggedHard}</div>
|
|
205
|
+
<div>Hard signals</div>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="card soft">
|
|
208
|
+
<div class="number">${flaggedSoft}</div>
|
|
209
|
+
<div>Soft flagged</div>
|
|
210
|
+
</div>
|
|
211
|
+
<div class="card clean">
|
|
212
|
+
<div class="number">${clean}</div>
|
|
213
|
+
<div>Clean</div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<h2>Triage — Ranked by Suspiciousness</h2>
|
|
218
|
+
<table id="triage-table">
|
|
219
|
+
<thead>
|
|
220
|
+
<tr>
|
|
221
|
+
<th onclick="sortTable(0, 'num')">Rank</th>
|
|
222
|
+
<th onclick="sortTable(1, 'str')">Participant</th>
|
|
223
|
+
<th onclick="sortTable(2, 'num')" title="Composite triage score (soft + hard + session signals)">Score</th>
|
|
224
|
+
<th onclick="sortTable(3, 'num')" title="Soft score for the chosen scoring config (session-authoritative when present, else per-trial sum)">Soft</th>
|
|
225
|
+
<th onclick="sortTable(4, 'str')">Hard</th>
|
|
226
|
+
<th class="session-col" onclick="sortTable(5, 'num')" title="AI extensions found">Ext</th>
|
|
227
|
+
<th class="session-col" onclick="sortTable(6, 'num')" title="Sidebar events (DOM gaps)">Sidebar</th>
|
|
228
|
+
<th class="session-col" onclick="sortTable(7, 'num')" title="DevTools-ish keyboard shortcuts">Kb</th>
|
|
229
|
+
<th class="session-col" onclick="sortTable(8, 'num')" title="Layout shifts + zoom changes">Layout</th>
|
|
230
|
+
<th onclick="sortTable(9, 'str')">Reason</th>
|
|
231
|
+
</tr>
|
|
232
|
+
</thead>
|
|
233
|
+
<tbody>
|
|
234
|
+
${triageRows}
|
|
235
|
+
</tbody>
|
|
236
|
+
</table>
|
|
237
|
+
|
|
238
|
+
<h2>Participant Details</h2>
|
|
239
|
+
${participantSections}
|
|
240
|
+
|
|
241
|
+
<p class="meta">Generated by cyborg-hunter v${VERSION}</p>
|
|
242
|
+
|
|
243
|
+
<script>
|
|
244
|
+
// Vanilla JS table sorting — no dependencies
|
|
245
|
+
function sortTable(col, type) {
|
|
246
|
+
const table = document.getElementById('triage-table');
|
|
247
|
+
const tbody = table.querySelector('tbody');
|
|
248
|
+
const rows = Array.from(tbody.querySelectorAll('tr'));
|
|
249
|
+
const dir = table.dataset.sortCol == col && table.dataset.sortDir == 'asc' ? 'desc' : 'asc';
|
|
250
|
+
table.dataset.sortCol = col;
|
|
251
|
+
table.dataset.sortDir = dir;
|
|
252
|
+
|
|
253
|
+
rows.sort((a, b) => {
|
|
254
|
+
let av = a.cells[col].textContent.trim();
|
|
255
|
+
let bv = b.cells[col].textContent.trim();
|
|
256
|
+
if (type === 'num') { av = parseFloat(av) || 0; bv = parseFloat(bv) || 0; }
|
|
257
|
+
if (av < bv) return dir === 'asc' ? -1 : 1;
|
|
258
|
+
if (av > bv) return dir === 'asc' ? 1 : -1;
|
|
259
|
+
return 0;
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
rows.forEach(r => tbody.appendChild(r));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function scrollToParticipant(id) {
|
|
266
|
+
const el = document.getElementById('p-' + id.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40));
|
|
267
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
268
|
+
}
|
|
269
|
+
</script>
|
|
270
|
+
<a href="#top" class="back-to-top">↑ Top</a>
|
|
271
|
+
|
|
272
|
+
<div id="lightbox" class="lightbox-overlay" role="dialog" aria-label="Enlarged image">
|
|
273
|
+
<span class="lightbox-close" aria-label="Close">×</span>
|
|
274
|
+
<img id="lightbox-img" alt="">
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
<script>
|
|
278
|
+
// P4 lightbox — intercept clicks on .zoomable anchors; ESC or click-outside closes.
|
|
279
|
+
(function() {
|
|
280
|
+
const overlay = document.getElementById('lightbox');
|
|
281
|
+
const img = document.getElementById('lightbox-img');
|
|
282
|
+
document.querySelectorAll('a.zoomable').forEach(a => {
|
|
283
|
+
a.addEventListener('click', e => {
|
|
284
|
+
e.preventDefault();
|
|
285
|
+
img.src = a.getAttribute('href');
|
|
286
|
+
overlay.classList.add('open');
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
overlay.addEventListener('click', () => {
|
|
290
|
+
overlay.classList.remove('open');
|
|
291
|
+
img.src = '';
|
|
292
|
+
});
|
|
293
|
+
document.addEventListener('keydown', e => {
|
|
294
|
+
if (e.key === 'Escape' && overlay.classList.contains('open')) {
|
|
295
|
+
overlay.classList.remove('open');
|
|
296
|
+
img.src = '';
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
})();
|
|
300
|
+
</script>
|
|
301
|
+
</body>
|
|
302
|
+
</html>`;
|
|
303
|
+
|
|
304
|
+
writeFileSync(join(config.outputDir, 'index.html'), html);
|
|
305
|
+
console.log(' index.html — report page');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function esc(str) {
|
|
309
|
+
return String(str || '')
|
|
310
|
+
.replace(/&/g, '&')
|
|
311
|
+
.replace(/</g, '<')
|
|
312
|
+
.replace(/>/g, '>')
|
|
313
|
+
.replace(/"/g, '"');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function sanitize(name) {
|
|
317
|
+
return String(name || '').replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Renders the tab-away stats cell. When the three-way split is available,
|
|
321
|
+
// show ≥10s / 3-10s / <3s (matches the timeline chart legend).
|
|
322
|
+
function formatTabAwayStat(s) {
|
|
323
|
+
const total = s.totalTabAways ?? 0;
|
|
324
|
+
const long = s.tabAwayLongCount;
|
|
325
|
+
const medium = s.tabAwayMediumCount;
|
|
326
|
+
const flicker = s.tabAwayFlickerCount;
|
|
327
|
+
if (long == null || medium == null || flicker == null) return String(total);
|
|
328
|
+
return `${total} (${long} ≥10s, ${medium} 3–10s, ${flicker} <3s)`;
|
|
329
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// src/cli/renderers/summary-csv.js
|
|
2
|
+
// Writes summary.csv — one row per participant with columns for every
|
|
3
|
+
// signal aggregate and the triage score/reason.
|
|
4
|
+
|
|
5
|
+
import { writeFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
|
|
8
|
+
// CSV columns in output order. Each entry: [header, accessor function].
|
|
9
|
+
const COLUMNS = [
|
|
10
|
+
['participantId', (s, t) => s.participantId],
|
|
11
|
+
['trialCount', (s, t) => s.trialCount],
|
|
12
|
+
['triageScore', (s, t) => t.score],
|
|
13
|
+
['hardTriggered', (s, t) => t.hardTriggered ? 'YES' : 'no'],
|
|
14
|
+
['triageReason', (s, t) => t.reason],
|
|
15
|
+
['totalPasteEvents', (s, t) => s.totalPasteEvents],
|
|
16
|
+
['totalCopyEvents', (s, t) => s.totalCopyEvents],
|
|
17
|
+
['totalDropEvents', (s, t) => s.totalDropEvents],
|
|
18
|
+
['totalTabAways', (s, t) => s.totalTabAways],
|
|
19
|
+
['tabAwayLongCount', (s, t) => s.tabAwayLongCount ?? 0], // ≥10s
|
|
20
|
+
['tabAwayMediumCount', (s, t) => s.tabAwayMediumCount ?? 0], // 3–10s
|
|
21
|
+
['tabAwayFlickerCount', (s, t) => s.tabAwayFlickerCount ?? 0], // <3s
|
|
22
|
+
['totalTabAwayDuration_ms', (s, t) => s.totalTabAwayDuration_ms],
|
|
23
|
+
['trialsWithTabAway', (s, t) => s.trialsWithTabAway],
|
|
24
|
+
['meanTypingSpeed', (s, t) => s.meanTypingSpeed.toFixed(2)],
|
|
25
|
+
['trialsWithFastTyping', (s, t) => s.trialsWithFastTyping],
|
|
26
|
+
['meanMouseEvents', (s, t) => s.meanMouseEvents.toFixed(1)],
|
|
27
|
+
['meanPathEfficiency', (s, t) => s.meanPathEfficiency.toFixed(3)],
|
|
28
|
+
['totalIdleGaps', (s, t) => s.totalIdleGaps],
|
|
29
|
+
['totalSyntheticInsertions', (s, t) => s.totalSyntheticInsertions],
|
|
30
|
+
['totalForeignInputEvents', (s, t) => s.totalForeignInputEvents],
|
|
31
|
+
['totalSoftScore', (s, t) => s.totalSoftScore],
|
|
32
|
+
['edgeExitCount', (s, t) => t.edgeExitCount],
|
|
33
|
+
// Session-level signals (Task 4) — drop legacy sidebarDetected/extensionsDetected
|
|
34
|
+
// columns in favour of richer session-authoritative counts.
|
|
35
|
+
['sidebar_event_count', (s, t) => s.sidebarEventCount ?? 0],
|
|
36
|
+
['ai_extensions', (s, t) => {
|
|
37
|
+
const ext = s.aiExtensionsFound || [];
|
|
38
|
+
return ext.map(e => typeof e === 'string' ? e : (e.name || 'unknown')).join('; ');
|
|
39
|
+
}],
|
|
40
|
+
['keyboard_shortcut_count', (s, t) => s.keyboardShortcutCount || 0],
|
|
41
|
+
['layout_shift_count', (s, t) => s.layoutShiftCount || 0],
|
|
42
|
+
['zoom_change_count', (s, t) => s.zoomChangeCount || 0],
|
|
43
|
+
['dev_tools_event_count', (s, t) => s.devToolsEventCount || 0],
|
|
44
|
+
['authoritative_soft_score', (s, t) => s.authoritativeSoftScore ?? ''],
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export async function renderSummaryCSV(summaries, triage, config) {
|
|
48
|
+
// Build a lookup from participantId → triage entry
|
|
49
|
+
const triageMap = new Map(triage.map(t => [t.participantId, t]));
|
|
50
|
+
|
|
51
|
+
const header = COLUMNS.map(c => c[0]).join(',');
|
|
52
|
+
const rows = summaries.map(s => {
|
|
53
|
+
const t = triageMap.get(s.participantId) || {};
|
|
54
|
+
return COLUMNS.map(c => escapeCSV(c[1](s, t))).join(',');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const csv = [header, ...rows].join('\n') + '\n';
|
|
58
|
+
const outPath = join(config.outputDir, 'summary.csv');
|
|
59
|
+
writeFileSync(outPath, csv);
|
|
60
|
+
console.log(` summary.csv — ${summaries.length} participants`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Escapes a CSV value: wraps in quotes if it contains commas, quotes, or newlines
|
|
64
|
+
function escapeCSV(val) {
|
|
65
|
+
const str = String(val ?? '');
|
|
66
|
+
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
|
67
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
68
|
+
}
|
|
69
|
+
return str;
|
|
70
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// src/cli/renderers/tab-timeline.js
|
|
2
|
+
// Renders tab-away timeline images using node-canvas.
|
|
3
|
+
// Produces one PNG per participant showing when tab-aways occurred
|
|
4
|
+
// relative to the experiment timeline.
|
|
5
|
+
//
|
|
6
|
+
// Layout: horizontal bar chart where X-axis = experiment time,
|
|
7
|
+
// each row = a trial, bars = tab-away events colored by duration.
|
|
8
|
+
// Bins match the analyzer split: flicker (<3s), medium (3-10s), long (≥10s).
|
|
9
|
+
// The 3s boundary is the scoring engine's soft-score cutoff.
|
|
10
|
+
|
|
11
|
+
import { writeFileSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
|
|
14
|
+
const ROW_HEIGHT = 25;
|
|
15
|
+
const LABEL_WIDTH = 120;
|
|
16
|
+
const CHART_WIDTH = 600;
|
|
17
|
+
const TOP_PAD = 50;
|
|
18
|
+
const BOTTOM_PAD = 30;
|
|
19
|
+
const LEFT_PAD = 10;
|
|
20
|
+
|
|
21
|
+
export async function renderTabTimelines(participants, config) {
|
|
22
|
+
const { createCanvas } = await import('canvas');
|
|
23
|
+
|
|
24
|
+
for (const p of participants) {
|
|
25
|
+
const trials = p.trials;
|
|
26
|
+
if (trials.length === 0) continue;
|
|
27
|
+
|
|
28
|
+
// Check if there are any tab-away events to render
|
|
29
|
+
const totalTabAways = trials.reduce((s, t) => s + (t.tabAwayEvents || []).length, 0);
|
|
30
|
+
if (totalTabAways === 0) continue;
|
|
31
|
+
|
|
32
|
+
const canvasH = TOP_PAD + trials.length * ROW_HEIGHT + BOTTOM_PAD;
|
|
33
|
+
const canvasW = LEFT_PAD + LABEL_WIDTH + CHART_WIDTH + 20;
|
|
34
|
+
const canvas = createCanvas(canvasW, canvasH);
|
|
35
|
+
const ctx = canvas.getContext('2d');
|
|
36
|
+
|
|
37
|
+
// Background
|
|
38
|
+
ctx.fillStyle = '#ffffff';
|
|
39
|
+
ctx.fillRect(0, 0, canvasW, canvasH);
|
|
40
|
+
|
|
41
|
+
// Title
|
|
42
|
+
ctx.fillStyle = '#333';
|
|
43
|
+
ctx.font = 'bold 14px sans-serif';
|
|
44
|
+
ctx.fillText(`Tab-Away Timeline — ${p.participantId}`, LEFT_PAD, 20);
|
|
45
|
+
ctx.font = '10px sans-serif';
|
|
46
|
+
ctx.fillStyle = '#666';
|
|
47
|
+
ctx.fillText(`${totalTabAways} tab-away events across ${trials.length} trials`, LEFT_PAD, 35);
|
|
48
|
+
|
|
49
|
+
// Legend (right-aligned under title)
|
|
50
|
+
const legend = [
|
|
51
|
+
{ label: 'trial duration', color: '#f0f0f0', border: '#ccc' },
|
|
52
|
+
{ label: '<3s flicker', color: '#bdbdbd', border: '#333' },
|
|
53
|
+
{ label: '3–10s', color: '#ff9800', border: '#333' },
|
|
54
|
+
{ label: '≥10s', color: '#f44336', border: '#333' },
|
|
55
|
+
];
|
|
56
|
+
let lx = canvasW - 10;
|
|
57
|
+
ctx.font = '9px sans-serif';
|
|
58
|
+
// Draw legend right-to-left
|
|
59
|
+
for (let k = legend.length - 1; k >= 0; k--) {
|
|
60
|
+
const item = legend[k];
|
|
61
|
+
const textW = ctx.measureText(item.label).width;
|
|
62
|
+
lx -= textW;
|
|
63
|
+
ctx.fillStyle = '#333';
|
|
64
|
+
ctx.fillText(item.label, lx, 22);
|
|
65
|
+
lx -= 4;
|
|
66
|
+
ctx.fillStyle = item.color;
|
|
67
|
+
ctx.fillRect(lx - 10, 15, 10, 8);
|
|
68
|
+
ctx.strokeStyle = item.border;
|
|
69
|
+
ctx.lineWidth = 0.5;
|
|
70
|
+
ctx.strokeRect(lx - 10, 15, 10, 8);
|
|
71
|
+
lx -= 14;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Find the maximum trial duration for X-axis scaling
|
|
75
|
+
const maxDuration = Math.max(...trials.map(t =>
|
|
76
|
+
(t.duration_ms || t.responseTime_ms || 30000)));
|
|
77
|
+
const xScale = CHART_WIDTH / maxDuration;
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < trials.length; i++) {
|
|
80
|
+
const trial = trials[i];
|
|
81
|
+
const y = TOP_PAD + i * ROW_HEIGHT;
|
|
82
|
+
const trialId = trial.trialId || trial.ruleId || `T${i + 1}`;
|
|
83
|
+
const tabAways = trial.tabAwayEvents || [];
|
|
84
|
+
|
|
85
|
+
// Row label
|
|
86
|
+
ctx.fillStyle = '#333';
|
|
87
|
+
ctx.font = '10px sans-serif';
|
|
88
|
+
ctx.fillText(trialId.substring(0, 16), LEFT_PAD, y + ROW_HEIGHT / 2 + 3);
|
|
89
|
+
|
|
90
|
+
// Trial duration background (light grey bar)
|
|
91
|
+
const trialDur = trial.duration_ms || trial.responseTime_ms || 30000;
|
|
92
|
+
const barX = LEFT_PAD + LABEL_WIDTH;
|
|
93
|
+
ctx.fillStyle = '#f0f0f0';
|
|
94
|
+
ctx.fillRect(barX, y + 3, trialDur * xScale, ROW_HEIGHT - 6);
|
|
95
|
+
|
|
96
|
+
// Tab-away bars
|
|
97
|
+
for (const ta of tabAways) {
|
|
98
|
+
// Use trial-relative timestamp (normalized in ingest). Fall back to
|
|
99
|
+
// raw `start` only if normalization was unavailable; this will look
|
|
100
|
+
// wrong (off-canvas) but at least won't crash.
|
|
101
|
+
const start = (ta.startRel_ms != null) ? ta.startRel_ms : (ta.start || 0);
|
|
102
|
+
const dur = ta.duration_ms || 0;
|
|
103
|
+
// Skip tab-aways that fall outside the trial window — they likely
|
|
104
|
+
// occurred between trials (inter-trial pause) and aren't meaningful here.
|
|
105
|
+
if (start < -1000 || start > trialDur + 1000) continue;
|
|
106
|
+
const clamped = Math.max(0, start);
|
|
107
|
+
const barStart = barX + clamped * xScale;
|
|
108
|
+
const barWidth = Math.max(2, dur * xScale);
|
|
109
|
+
|
|
110
|
+
// Color by duration: flicker (<3s) = grey, medium (3-10s) = orange, long (≥10s) = red.
|
|
111
|
+
// Matches summary analyzer bins and the scoring engine's 3s cutoff.
|
|
112
|
+
const durSec = dur / 1000;
|
|
113
|
+
ctx.fillStyle = durSec < 3 ? '#bdbdbd' : durSec < 10 ? '#ff9800' : '#f44336';
|
|
114
|
+
ctx.fillRect(barStart, y + 5, barWidth, ROW_HEIGHT - 10);
|
|
115
|
+
ctx.strokeStyle = '#333';
|
|
116
|
+
ctx.lineWidth = 0.5;
|
|
117
|
+
ctx.strokeRect(barStart, y + 5, barWidth, ROW_HEIGHT - 10);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Row separator
|
|
121
|
+
ctx.strokeStyle = '#e0e0e0';
|
|
122
|
+
ctx.lineWidth = 0.5;
|
|
123
|
+
ctx.beginPath();
|
|
124
|
+
ctx.moveTo(LEFT_PAD, y + ROW_HEIGHT);
|
|
125
|
+
ctx.lineTo(canvasW - 10, y + ROW_HEIGHT);
|
|
126
|
+
ctx.stroke();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// X-axis labels
|
|
130
|
+
ctx.fillStyle = '#666';
|
|
131
|
+
ctx.font = '9px sans-serif';
|
|
132
|
+
const tickCount = 5;
|
|
133
|
+
for (let t = 0; t <= tickCount; t++) {
|
|
134
|
+
const time = (maxDuration / tickCount) * t;
|
|
135
|
+
const x = LEFT_PAD + LABEL_WIDTH + time * xScale;
|
|
136
|
+
ctx.fillText(`${(time / 1000).toFixed(0)}s`, x, canvasH - 10);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const buf = canvas.toBuffer('image/png');
|
|
140
|
+
const filename = `tab_timeline_${sanitize(p.participantId)}.png`;
|
|
141
|
+
writeFileSync(join(config.outputDir, 'images', filename), buf);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
console.log(` tab timelines — rendered`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function sanitize(name) {
|
|
148
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
|
|
149
|
+
}
|