cyborg-hunter 0.5.0 → 0.7.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/CITATION.cff +29 -0
  3. package/LICENSE +21 -0
  4. package/README.md +78 -21
  5. package/package.json +10 -3
  6. package/src/cli/analyzers/edge-exit.js +4 -1
  7. package/src/cli/analyzers/phase-scope.js +83 -0
  8. package/src/cli/analyzers/summary.js +161 -28
  9. package/src/cli/analyzers/triage.js +59 -27
  10. package/src/cli/config.js +26 -1
  11. package/src/cli/ingest.js +623 -41
  12. package/src/cli/init.js +1 -1
  13. package/src/cli/renderers/event-log.js +18 -19
  14. package/src/cli/renderers/extensions.js +12 -3
  15. package/src/cli/renderers/html-index.js +163 -24
  16. package/src/cli/renderers/replay-assets.js +177 -0
  17. package/src/cli/renderers/replay-viewer.client.js +1022 -0
  18. package/src/cli/renderers/session-timeline.js +917 -0
  19. package/src/cli/renderers/summary-csv.js +5 -0
  20. package/src/cli/renderers/trajectories.js +68 -7
  21. package/src/cli/renderers/triage-md.js +10 -4
  22. package/src/cli/renderers/typing-profile.js +7 -1
  23. package/src/cli/report.js +42 -8
  24. package/src/core/monitor.js +60 -7
  25. package/src/core/scoring.js +11 -2
  26. package/src/core/signals/browser.js +51 -18
  27. package/src/core/signals/clipboard.js +10 -2
  28. package/src/core/signals/dom-protection.js +9 -0
  29. package/src/core/signals/focus.js +16 -2
  30. package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
  31. package/src/jspsych/extension-cyborg-hunter.js +9 -2
  32. package/src/jspsych/extension-guard-friction.js +32 -12
  33. package/src/jspsych/extension-guard-honeypot.js +25 -1
  34. package/src/replay/capture-dom.js +575 -0
  35. package/src/replay/capture-trace.js +468 -0
  36. package/src/replay/index.js +104 -0
  37. package/src/replay/persistence.js +141 -0
  38. package/src/replay/recorder.js +315 -0
  39. package/src/replay/serializer.js +119 -0
  40. package/src/shared/constants.js +12 -6
  41. package/src/shared/schema.js +5 -0
  42. package/src/shared/validation.js +55 -0
  43. package/dist/cyborg-hunter.esm.js +0 -1527
  44. package/dist/cyborg-hunter.min.js +0 -6
  45. package/dist/extension-cyborg-hunter.js +0 -1
  46. package/dist/extension-guard-friction.js +0 -36
  47. package/dist/extension-guard-honeypot.js +0 -1
  48. package/src/cli/renderers/tab-timeline.js +0 -149
@@ -0,0 +1,468 @@
1
+ // src/replay/capture-trace.js
2
+ // Tier-1 ("trace") capture: pointer, keys, clipboard, input values, scroll,
3
+ // touch, focus/visibility, viewport. Attaches everything through the
4
+ // recorder's listener registry so destroy() tears it all down.
5
+ //
6
+ // Coordinate contract (cursor-alignment fix): every pointer event
7
+ // carries BOTH page coords (x/y — document space, survives any later scroll
8
+ // math) and client coords (cx/cy — viewport space, what the viewer actually
9
+ // draws). Discrete interactions additionally carry a target anchor and are
10
+ // preceded by a synchronous flush of pending camera state (scroll/resize),
11
+ // so the event order on the wire can never invert the state the interaction
12
+ // happened under.
13
+ //
14
+ // Testability: the environment (doc/win/now/raf) is injectable. In the
15
+ // browser, callers omit `env` and the real globals are used.
16
+ //
17
+ // Failure containment: every handler body runs inside guard() — a throwing
18
+ // capture channel logs one captureFailure and the experiment continues.
19
+
20
+ // Wrap a handler so an exception can never propagate into the host page.
21
+ function guard(rec, channel, fn) {
22
+ return function (e) {
23
+ try {
24
+ fn(e);
25
+ } catch (err) {
26
+ rec.captureFailure(channel, err);
27
+ }
28
+ };
29
+ }
30
+
31
+ // Compact element descriptor for event payloads: "input#answer", "textarea".
32
+ function describeEl(el) {
33
+ if (!el || !el.tagName) return null;
34
+ var d = el.tagName.toLowerCase();
35
+ if (el.id) d += '#' + el.id;
36
+ return d;
37
+ }
38
+
39
+ function isPassword(el) {
40
+ return !!(el && el.tagName === 'INPUT' && el.type === 'password');
41
+ }
42
+
43
+ function matchesRedact(el, selector) {
44
+ if (isPassword(el)) return true; // unconditional, not overridable
45
+ if (!selector || !el || typeof el.matches !== 'function') return false;
46
+ try { return el.matches(selector); } catch (e) { return false; }
47
+ }
48
+
49
+ // Subtree semantics, matching capture-dom's isInRedactedSubtree: a click on
50
+ // a descendant of a redacted container must not leak the descendant's
51
+ // identity or geometry through an anchor.
52
+ function inRedactedSubtree(el, selector) {
53
+ var cur = el;
54
+ while (cur) {
55
+ if (matchesRedact(cur, selector)) return true;
56
+ cur = cur.parentNode;
57
+ }
58
+ return false;
59
+ }
60
+
61
+ // Round to 0.1px — anchor rects feed pixel-tolerance checks; full float
62
+ // precision would just bloat the JSON.
63
+ function r10(v) { return Math.round(v * 10) / 10; }
64
+
65
+ export function attachTraceCapture(rec, env) {
66
+ env = env || {};
67
+ var doc = env.doc || document;
68
+ var win = env.win || window;
69
+ var now = env.now || function () { return performance.now(); };
70
+ var raf = env.raf || (typeof requestAnimationFrame !== 'undefined'
71
+ ? requestAnimationFrame.bind(null)
72
+ : function (fn) { setTimeout(fn, 16); });
73
+ var config = rec.config;
74
+
75
+ function clientDims() {
76
+ var de = doc.documentElement;
77
+ return {
78
+ cw: de && de.clientWidth ? de.clientWidth : (win.innerWidth || null),
79
+ ch: de && de.clientHeight ? de.clientHeight : (win.innerHeight || null)
80
+ };
81
+ }
82
+
83
+ // Client coords appended to a payload when the event carries them (real
84
+ // browsers always do; duck-typed test events may not).
85
+ function withClient(payload, e) {
86
+ if (e.clientX != null) payload.cx = Math.round(e.clientX);
87
+ if (e.clientY != null) payload.cy = Math.round(e.clientY);
88
+ return payload;
89
+ }
90
+
91
+ // ── Interaction anchor ──
92
+ // Identity + geometry of what a discrete interaction hit, captured at
93
+ // CAPTURE PHASE (before page handlers can remove the target or stop the
94
+ // recording — the smoke fixture's Finish click was lost to exactly that).
95
+ // Client-space rect: raw getBoundingClientRect, no scroll math — the
96
+ // viewer compares it against the reconstruction's own client-space rect.
97
+ // Redacted targets expose only {redacted, tag}: no id, rect, or marker,
98
+ // so redaction never gains new leakage through anchors.
99
+ function anchorFor(e) {
100
+ var el = e && e.target;
101
+ if (!el || !el.tagName) return null;
102
+ var tag = el.tagName.toLowerCase();
103
+ // inRedactedSubtree → matchesRedact, whose FIRST check is isPassword():
104
+ // password inputs are unconditionally anchor-redacted, selector or not,
105
+ // and so is anything inside a redactSelector-matched container.
106
+ if (inRedactedSubtree(el, config.redactSelector)) {
107
+ return { redacted: true, tag: tag };
108
+ }
109
+ var a = { tag: tag };
110
+ // Shadow retargeting, detected at EVENT time: for OPEN shadow roots the
111
+ // composed path's first entry is the real (shadow-internal) target while
112
+ // e.target is the retargeted host — regardless of when attachShadow()
113
+ // ran (a post-snapshot attach leaves no mutation record). The viewer
114
+ // refuses to "verify" such interactions against the hollow host.
115
+ // CLOSED roots are undetectable from outside by design — documented
116
+ // limitation, same class as CSSOM-only content changes.
117
+ try {
118
+ if (typeof e.composedPath === 'function') {
119
+ var path0 = e.composedPath()[0];
120
+ if (path0 && path0 !== el) a.shadow = true;
121
+ }
122
+ } catch (err) { /* composedPath unavailable — snapshot marking still applies */ }
123
+ if (el.id) a.id = el.id;
124
+ var markers = typeof rec.getMarkers === 'function' ? rec.getMarkers() : null;
125
+ if (markers) a.n = markers.refFor(el);
126
+ try {
127
+ if (typeof el.getBoundingClientRect === 'function') {
128
+ var r = el.getBoundingClientRect();
129
+ a.rect = [r10(r.left), r10(r.top), r10(r.width), r10(r.height)];
130
+ }
131
+ } catch (e) { /* rect stays absent — viewer treats as unverifiable */ }
132
+ return a;
133
+ }
134
+
135
+ // ── Camera state: window/element scroll + viewport, RAF-coalesced ──
136
+ // Two invariants the viewer's projection depends on:
137
+ // 1. ORIGINAL timestamps: a coalesced event is stamped with the time of
138
+ // the last underlying DOM event, not the RAF flush — otherwise a click
139
+ // recorded between the scroll and its flush sorts BEFORE the scroll and
140
+ // plays back under the stale camera (full-scroll-jump error).
141
+ // 2. Synchronous flush before discrete events: pending camera state is
142
+ // pushed BEFORE the interaction that follows it, in the same task.
143
+ var pendingWindowScroll = null; // { t } — values read at flush
144
+ var pendingElementScrolls = new Map(); // target → { t } — per target, not last-wins
145
+ var pendingResize = null; // { t }
146
+ var pendingVv = null; // { t }
147
+ var scrollFlushQueued = false;
148
+ var resizeFlushQueued = false;
149
+ var vvFlushQueued = false;
150
+
151
+ function flushScrolls() {
152
+ scrollFlushQueued = false;
153
+ if (pendingWindowScroll) {
154
+ rec.pushEvent('scroll', withClientlessScroll({
155
+ x: Math.round(win.scrollX || 0), y: Math.round(win.scrollY || 0)
156
+ }), pendingWindowScroll.t);
157
+ pendingWindowScroll = null;
158
+ }
159
+ if (pendingElementScrolls.size > 0) {
160
+ pendingElementScrolls.forEach(function (state, target) {
161
+ var payload;
162
+ if (inRedactedSubtree(target, config.redactSelector)) {
163
+ // Same contract as interaction anchors: a scroller inside a
164
+ // redacted subtree exposes no id, descriptor, or marker. Offsets
165
+ // alone stay (they carry no content), unresolvable by design.
166
+ payload = {
167
+ redacted: true,
168
+ tag: target.tagName ? target.tagName.toLowerCase() : null,
169
+ x: Math.round(target.scrollLeft || 0),
170
+ y: Math.round(target.scrollTop || 0)
171
+ };
172
+ } else {
173
+ payload = {
174
+ el: describeEl(target),
175
+ id: target.id || null,
176
+ x: Math.round(target.scrollLeft || 0),
177
+ y: Math.round(target.scrollTop || 0)
178
+ };
179
+ var markers = typeof rec.getMarkers === 'function' ? rec.getMarkers() : null;
180
+ if (markers) payload.n = markers.refFor(target);
181
+ }
182
+ rec.pushEvent('scroll', payload, state.t);
183
+ });
184
+ pendingElementScrolls.clear();
185
+ }
186
+ }
187
+ // Window-scroll payloads carry no element fields; tiny helper keeps the
188
+ // shape explicit rather than relying on undefined-stripping.
189
+ function withClientlessScroll(p) { return p; }
190
+
191
+ function flushResize() {
192
+ resizeFlushQueued = false;
193
+ if (!pendingResize) return;
194
+ var dims = clientDims();
195
+ rec.pushEvent('resize', {
196
+ w: win.innerWidth, h: win.innerHeight,
197
+ cw: dims.cw, ch: dims.ch,
198
+ dpr: win.devicePixelRatio || 1
199
+ }, pendingResize.t);
200
+ pendingResize = null;
201
+ }
202
+
203
+ function flushVv() {
204
+ vvFlushQueued = false;
205
+ if (!pendingVv) return;
206
+ var vv = win.visualViewport;
207
+ if (vv) {
208
+ rec.pushEvent('vv', {
209
+ w: r10(vv.width), h: r10(vv.height), scale: vv.scale,
210
+ px: r10(vv.pageLeft || 0), py: r10(vv.pageTop || 0)
211
+ }, pendingVv.t);
212
+ }
213
+ pendingVv = null;
214
+ }
215
+
216
+ // Synchronous camera flush — called at the top of every discrete-event
217
+ // handler so pending state is IN THE BUFFER before the interaction lands.
218
+ // Emission order within this function (and Map iteration order) is
219
+ // deliberately irrelevant: every event carries its ORIGINAL timestamp and
220
+ // the wire order is established by the serializer's stable sort on t
221
+ // (serializer.js serialize(); replay-assets.js buildViewerModel() sorts
222
+ // again defensively). The buffer has never been chronologically ordered —
223
+ // RAF-coalesced input events pre-date this and flush late the same way.
224
+ function flushCameraNow() {
225
+ flushScrolls();
226
+ flushResize();
227
+ flushVv();
228
+ }
229
+
230
+ // Camera SNAPSHOT stamped onto every discrete interaction. Flushing alone
231
+ // is not enough: browsers dispatch scroll/resize NOTIFICATIONS
232
+ // asynchronously after programmatic scrolls (scrollIntoView, scrollTo,
233
+ // anchor jumps), so a click can be recorded before the scroll event that
234
+ // describes the state it happened under — with nothing pending to flush.
235
+ // The snapshot reads the live state synchronously in the interaction's own
236
+ // handler; the viewer treats it as an authoritative camera observation.
237
+ function withCameraSnapshot(payload) {
238
+ var dims = clientDims();
239
+ payload.sx = Math.round(win.scrollX || 0);
240
+ payload.sy = Math.round(win.scrollY || 0);
241
+ payload.vw = win.innerWidth || null;
242
+ payload.vh = win.innerHeight || null;
243
+ payload.cw = dims.cw;
244
+ payload.ch = dims.ch;
245
+ return payload;
246
+ }
247
+
248
+ // ── Mouse ──
249
+ // mousemove throttled to mouseHz; down/up/click are low-frequency and
250
+ // carry adjudication weight, so they always record — with anchors.
251
+ // All pointer listeners are CAPTURE-PHASE: page handlers may remove the
252
+ // target, stopPropagation, or stop the recording before bubble reaches
253
+ // the document.
254
+ var minMoveGap = 1000 / (config.mouseHz || 30);
255
+ var lastMove = -Infinity;
256
+ rec.addListener(doc, 'mousemove', guard(rec, 'mouse', function (e) {
257
+ var t = now();
258
+ if (t - lastMove < minMoveGap) return;
259
+ lastMove = t;
260
+ rec.pushEvent('mousemove', withClient({
261
+ x: Math.round(e.pageX), y: Math.round(e.pageY)
262
+ }, e), t);
263
+ }), { passive: true, capture: true });
264
+ ['mousedown', 'mouseup', 'click'].forEach(function (kind) {
265
+ rec.addListener(doc, kind, guard(rec, 'mouse', function (e) {
266
+ flushCameraNow();
267
+ var payload = withCameraSnapshot(withClient({
268
+ x: Math.round(e.pageX), y: Math.round(e.pageY)
269
+ }, e));
270
+ var anchor = anchorFor(e);
271
+ if (anchor) payload.target = anchor;
272
+ rec.pushEvent(kind, payload, now());
273
+ }), { passive: true, capture: true });
274
+ });
275
+
276
+ // ── Keys ──
277
+ // keys:'off' attaches nothing. Password fields AND any field matching
278
+ // redactSelector never record key identity (redacted flag only) — otherwise
279
+ // a redacted field's text is reconstructable keystroke-by-keystroke, which
280
+ // would defeat the same redactSelector the input-value capture already honors.
281
+ if (config.keys !== 'off') {
282
+ ['keydown', 'keyup'].forEach(function (kind) {
283
+ rec.addListener(doc, kind, guard(rec, 'keys', function (e) {
284
+ // SUBTREE semantics (same as anchors/DOM capture): typing into a
285
+ // field INSIDE a redacted container must not leak key identity —
286
+ // the direct-target check alone let descendants through.
287
+ if (inRedactedSubtree(e.target, config.redactSelector)) {
288
+ rec.pushEvent(kind, { redacted: true }, now());
289
+ return;
290
+ }
291
+ rec.pushEvent(kind, { key: e.key, code: e.code }, now());
292
+ }), true); // capture phase — before frameworks can stopPropagation
293
+ });
294
+ }
295
+
296
+ // ── Clipboard ──
297
+ // Lengths only. Content stays out of the replay stream by design; CH's own
298
+ // pasteDropContent flag governs content capture in the integrity report.
299
+ rec.addListener(doc, 'paste', guard(rec, 'clipboard', function (e) {
300
+ flushCameraNow();
301
+ var len = null;
302
+ try { len = e.clipboardData.getData('text').length; } catch (err) { /* len stays null */ }
303
+ rec.pushEvent('paste', { len: len }, now());
304
+ }), true);
305
+ rec.addListener(doc, 'copy', guard(rec, 'clipboard', function () {
306
+ flushCameraNow();
307
+ rec.pushEvent('copy', {}, now());
308
+ }), true);
309
+ rec.addListener(doc, 'cut', guard(rec, 'clipboard', function () {
310
+ flushCameraNow();
311
+ rec.pushEvent('cut', {}, now());
312
+ }), true);
313
+ rec.addListener(doc, 'drop', guard(rec, 'clipboard', function (e) {
314
+ flushCameraNow();
315
+ var len = null;
316
+ try { len = e.dataTransfer.getData('text').length; } catch (err) { /* len stays null */ }
317
+ rec.pushEvent('ch:drop', { len: len }, now());
318
+ }), true);
319
+
320
+ // ── Input values (RAF-coalesced per target) ──
321
+ // Rapid typing produces one event per frame per field, holding the LAST
322
+ // value — enough to reconstruct field state on scrub without recording
323
+ // every intermediate keystroke twice (keydown already carries timing).
324
+ var pendingInputs = new Map(); // target → latest event time
325
+ var inputFlushQueued = false;
326
+ function flushInputs() {
327
+ inputFlushQueued = false;
328
+ pendingInputs.forEach(function (t, target) {
329
+ // el is a display descriptor; id is the RAW id for the viewer to
330
+ // resolve via getElementById (ids like "a:b c" are not selector-safe).
331
+ var payload = { el: describeEl(target), id: target.id || null };
332
+ var value = target.value != null ? String(target.value)
333
+ : (target.isContentEditable && target.textContent != null
334
+ ? String(target.textContent) : '');
335
+ // SUBTREE semantics: a field inside a redacted container leaks no
336
+ // value. The id/el descriptor stays (the DOM snapshot already carries
337
+ // redacted elements' ids; the viewer needs a target for the bullets).
338
+ if (inRedactedSubtree(target, config.redactSelector)) {
339
+ payload.redacted = true;
340
+ payload.value_len = value.length;
341
+ } else {
342
+ payload.value = value;
343
+ // Marker ref for duplicate-id-safe restore (non-redacted only:
344
+ // redacted events gain no NEW identity fields).
345
+ var inputMarkers = typeof rec.getMarkers === 'function' ? rec.getMarkers() : null;
346
+ if (inputMarkers) payload.n = inputMarkers.refFor(target);
347
+ }
348
+ // checked is an element PROPERTY (never an attribute mutation), so
349
+ // the DOM replay can only restore checkbox/radio state from here —
350
+ // but a redacted field's checked state is still its VALUE: withhold it.
351
+ if (!payload.redacted && (target.type === 'checkbox' || target.type === 'radio')) {
352
+ payload.checked = !!target.checked;
353
+ }
354
+ rec.pushEvent('input', payload, t);
355
+ });
356
+ pendingInputs.clear();
357
+ }
358
+ rec.addListener(doc, 'input', guard(rec, 'input', function (e) {
359
+ if (!e.target) return;
360
+ pendingInputs.set(e.target, now());
361
+ if (!inputFlushQueued) {
362
+ inputFlushQueued = true;
363
+ raf(guard(rec, 'input', flushInputs));
364
+ }
365
+ }), true);
366
+
367
+ // ── Scroll (RAF-coalesced, per target, original timestamps) ──
368
+ rec.addListener(win, 'scroll', guard(rec, 'scroll', function (e) {
369
+ var target = e && e.target && e.target.tagName ? e.target : null;
370
+ if (target) {
371
+ pendingElementScrolls.set(target, { t: now() });
372
+ } else {
373
+ pendingWindowScroll = { t: now() };
374
+ }
375
+ if (!scrollFlushQueued) {
376
+ scrollFlushQueued = true;
377
+ raf(guard(rec, 'scroll', flushScrolls));
378
+ }
379
+ }), { passive: true, capture: true });
380
+
381
+ // ── Touch ──
382
+ rec.addListener(doc, 'touchstart', guard(rec, 'touch', function (e) {
383
+ flushCameraNow();
384
+ var p = e.touches && e.touches[0];
385
+ if (!p) return;
386
+ var payload = withCameraSnapshot(
387
+ withClient({ x: Math.round(p.pageX), y: Math.round(p.pageY) }, p));
388
+ var anchor = anchorFor(e);
389
+ if (anchor) payload.target = anchor;
390
+ rec.pushEvent('touchstart', payload, now());
391
+ }), { passive: true, capture: true });
392
+ var touchPending = false;
393
+ var lastTouch = null;
394
+ var lastTouchT = 0;
395
+ rec.addListener(doc, 'touchmove', guard(rec, 'touch', function (e) {
396
+ lastTouch = e.touches && e.touches[0];
397
+ lastTouchT = now();
398
+ if (touchPending) return;
399
+ touchPending = true;
400
+ raf(guard(rec, 'touch', function () {
401
+ touchPending = false;
402
+ if (lastTouch) {
403
+ rec.pushEvent('touchmove', withClient({
404
+ x: Math.round(lastTouch.pageX), y: Math.round(lastTouch.pageY)
405
+ }, lastTouch), lastTouchT);
406
+ }
407
+ }));
408
+ }), { passive: true, capture: true });
409
+ rec.addListener(doc, 'touchend', guard(rec, 'touch', function (e) {
410
+ flushCameraNow();
411
+ var p = e.changedTouches && e.changedTouches[0];
412
+ if (!p) return;
413
+ var payload = withCameraSnapshot(
414
+ withClient({ x: Math.round(p.pageX), y: Math.round(p.pageY) }, p));
415
+ var anchor = anchorFor(e);
416
+ if (anchor) payload.target = anchor;
417
+ rec.pushEvent('touchend', payload, now());
418
+ }), { passive: true, capture: true });
419
+
420
+ // ── Focus / visibility ──
421
+ rec.addListener(win, 'blur', guard(rec, 'focus', function () {
422
+ rec.pushEvent('blur', {}, now());
423
+ }));
424
+ rec.addListener(win, 'focus', guard(rec, 'focus', function () {
425
+ rec.pushEvent('focus', {}, now());
426
+ }));
427
+ rec.addListener(doc, 'visibilitychange', guard(rec, 'focus', function () {
428
+ rec.pushEvent('visibility', { hidden: !!doc.hidden }, now());
429
+ }));
430
+
431
+ // ── Viewport (RAF-coalesced resize; event-driven, no polling) ──
432
+ rec.addListener(win, 'resize', guard(rec, 'viewport', function () {
433
+ pendingResize = { t: now() };
434
+ if (!resizeFlushQueued) {
435
+ resizeFlushQueued = true;
436
+ raf(guard(rec, 'viewport', flushResize));
437
+ }
438
+ }));
439
+ // visualViewport: size/scale AND pan (scroll). Pinch-pan does not move the
440
+ // pointer relative to the DOM (page/client coords are layout-viewport CSS
441
+ // px), so vv is visible-region METADATA, not part of the cursor projection
442
+ // — recorded so the viewer can later show what the participant could see.
443
+ if (win.visualViewport && typeof win.visualViewport.addEventListener === 'function') {
444
+ ['resize', 'scroll'].forEach(function (kind) {
445
+ rec.addListener(win.visualViewport, kind, guard(rec, 'viewport', function () {
446
+ pendingVv = { t: now() };
447
+ if (!vvFlushQueued) {
448
+ vvFlushQueued = true;
449
+ raf(guard(rec, 'viewport', flushVv));
450
+ }
451
+ }));
452
+ });
453
+ }
454
+
455
+ // ── Per-trial camera seed ──
456
+ // Scroll + viewport state at trial start. Without this, a trial that
457
+ // begins mid-scroll (the user's fixture, trial 2) replays from a wrong
458
+ // camera until its first in-trial scroll/resize event.
459
+ rec.onTrialStart(function (trial) {
460
+ var dims = clientDims();
461
+ trial.viewState = {
462
+ x: Math.round(win.scrollX || 0), y: Math.round(win.scrollY || 0),
463
+ w: win.innerWidth || null, h: win.innerHeight || null,
464
+ cw: dims.cw, ch: dims.ch,
465
+ dpr: win.devicePixelRatio || 1
466
+ };
467
+ });
468
+ }
@@ -0,0 +1,104 @@
1
+ // src/replay/index.js
2
+ // Public assembly for the replay recorder: singleton attach(), capture
3
+ // wiring by tier, serialization + persistence surface.
4
+ //
5
+ // Standalone usage (no jsPsych):
6
+ // const rec = CyborgHunterReplay.attach({ participantId, tier: 'dom',
7
+ // autoSave: { mode: 'datapipe', experimentId: 'ABC' } });
8
+ // rec.startSession();
9
+ // rec.startTrial({ trialId: 'r1' }); // optional bracketing
10
+ // rec.endTrial();
11
+ // rec.stopSession('finished');
12
+ // await rec.autoSaveNow(); // or rec.getRecording() and DIY
13
+ // rec.destroy();
14
+
15
+ import { createRecorder } from './recorder.js';
16
+ import { attachTraceCapture } from './capture-trace.js';
17
+ import { attachDomCapture } from './capture-dom.js';
18
+ import { serialize } from './serializer.js';
19
+ import {
20
+ replayFilename, buildReplayMeta, autoSave, compressRecording,
21
+ } from './persistence.js';
22
+
23
+ var _active = null;
24
+
25
+ export function attach(userConfig) {
26
+ if (_active) {
27
+ console.warn('[cyborg-hunter-replay] attach() called while a recorder is active — replacing the previous instance (its unsaved recording is discarded).');
28
+ _active.destroy();
29
+ _active = null;
30
+ }
31
+
32
+ var rec = createRecorder(userConfig);
33
+
34
+ var api = {
35
+ // Exposed for tests and advanced integrations; not part of the
36
+ // documented surface.
37
+ _recorder: rec,
38
+ config: rec.config,
39
+
40
+ startSession: function () {
41
+ rec.startSession();
42
+ // Capture modules attach after the session transition so nothing
43
+ // touches the DOM until the researcher opts in.
44
+ attachTraceCapture(rec);
45
+ if (rec.config.tier === 'dom' || rec.config.tier === 'canvas') {
46
+ // 'canvas' is accepted for forward compat but captures at dom tier
47
+ // in v0.7 (canvas snapshots arrive with the v0.8 diff codec).
48
+ attachDomCapture(rec);
49
+ }
50
+ return api;
51
+ },
52
+
53
+ startTrial: function (opts) { rec.startTrial(opts); return api; },
54
+ endTrial: function () { rec.endTrial(); return api; },
55
+ stopSession: function (reason) { rec.stopSession(reason); return api; },
56
+
57
+ getRecording: function (opts) {
58
+ return serialize(rec.getState(), opts || {});
59
+ },
60
+
61
+ getRecordingCompressed: function (opts) {
62
+ return compressRecording(api.getRecording(opts));
63
+ },
64
+
65
+ // Serialize + persist with the configured autoSave mode; returns
66
+ // { recording, saveResult, meta } so callers (the jsPsych adapter, or a
67
+ // standalone researcher) can attach the meta pointer to their data.
68
+ // Teardown-safe: if the session never started (nothing to serialize),
69
+ // it degrades to a no_session result instead of throwing, so a finish
70
+ // path is never interrupted — while a DIRECT getRecording() before
71
+ // startSession still throws to surface the programmer error.
72
+ autoSaveNow: async function (opts) {
73
+ if (rec.getState().sessionStart == null) {
74
+ return {
75
+ recording: null,
76
+ saveResult: { saved_to: 'no_session' },
77
+ meta: { schema_version: 1, saved_to: 'no_session',
78
+ note: 'recorder was never started; nothing to save' }
79
+ };
80
+ }
81
+ var recording = api.getRecording(opts);
82
+ var saveResult = await autoSave(recording, rec.config.autoSave);
83
+ var meta = buildReplayMeta(recording, saveResult.saved_to);
84
+ if (saveResult.error) meta.save_error = saveResult.error;
85
+ return { recording: recording, saveResult: saveResult, meta: meta };
86
+ },
87
+
88
+ destroy: function () {
89
+ rec.destroy();
90
+ if (_active === api) _active = null;
91
+ }
92
+ };
93
+
94
+ _active = api;
95
+ return api;
96
+ }
97
+
98
+ export { replayFilename, buildReplayMeta, serialize };
99
+
100
+ // Browser global — same pattern as the guard extensions (explicit window
101
+ // assignment; no esbuild globalName).
102
+ if (typeof window !== 'undefined') {
103
+ window.CyborgHunterReplay = { attach: attach };
104
+ }