cyborg-hunter 0.5.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/CITATION.cff +29 -0
  3. package/LICENSE +21 -0
  4. package/README.md +80 -21
  5. package/bin/cyborg-hunter.js +6 -2
  6. package/dist/cyborg-hunter-replay.js +3 -0
  7. package/dist/cyborg-hunter.esm.js +114 -22
  8. package/dist/cyborg-hunter.min.js +3 -3
  9. package/dist/extension-cyborg-hunter.js +1 -1
  10. package/dist/extension-guard-friction.js +5 -5
  11. package/dist/extension-guard-honeypot.js +1 -1
  12. package/package.json +15 -3
  13. package/src/cli/analyzers/edge-exit.js +4 -1
  14. package/src/cli/analyzers/phase-scope.js +83 -0
  15. package/src/cli/analyzers/summary.js +161 -28
  16. package/src/cli/analyzers/triage.js +59 -27
  17. package/src/cli/config.js +26 -1
  18. package/src/cli/extract-core.js +552 -0
  19. package/src/cli/ingest.js +250 -193
  20. package/src/cli/init.js +1 -1
  21. package/src/cli/preview-entry.js +36 -0
  22. package/src/cli/renderers/event-log.js +18 -19
  23. package/src/cli/renderers/extensions.js +12 -3
  24. package/src/cli/renderers/html-index-core.js +1273 -0
  25. package/src/cli/renderers/html-index.js +13 -1047
  26. package/src/cli/renderers/replay-assets.js +67 -0
  27. package/src/cli/renderers/replay-viewer.client.js +1185 -0
  28. package/src/cli/renderers/session-timeline-core.js +907 -0
  29. package/src/cli/renderers/session-timeline.js +33 -0
  30. package/src/cli/renderers/summary-csv.js +5 -0
  31. package/src/cli/renderers/trajectories-core.js +717 -0
  32. package/src/cli/renderers/trajectories.js +31 -635
  33. package/src/cli/renderers/triage-md.js +10 -4
  34. package/src/cli/renderers/typing-profile-core.js +211 -0
  35. package/src/cli/renderers/typing-profile.js +16 -186
  36. package/src/cli/report.js +42 -8
  37. package/src/core/monitor.js +77 -7
  38. package/src/core/scoring.js +11 -2
  39. package/src/core/signals/browser.js +51 -18
  40. package/src/core/signals/clipboard.js +10 -2
  41. package/src/core/signals/dom-protection.js +9 -0
  42. package/src/core/signals/focus.js +16 -2
  43. package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
  44. package/src/jspsych/extension-cyborg-hunter.js +9 -2
  45. package/src/jspsych/extension-guard-friction.js +43 -14
  46. package/src/jspsych/extension-guard-honeypot.js +25 -1
  47. package/src/replay/capture-dom.js +575 -0
  48. package/src/replay/capture-trace.js +468 -0
  49. package/src/replay/index.js +104 -0
  50. package/src/replay/persistence.js +141 -0
  51. package/src/replay/recorder.js +315 -0
  52. package/src/replay/serializer.js +119 -0
  53. package/src/replay/viewer-model.js +125 -0
  54. package/src/shared/constants.js +12 -6
  55. package/src/shared/paths.js +20 -0
  56. package/src/shared/schema.js +5 -0
  57. package/src/shared/validation.js +55 -0
  58. package/src/cli/renderers/tab-timeline.js +0 -149
@@ -0,0 +1,1185 @@
1
+ // src/cli/renderers/replay-viewer.client.js
2
+ // Browser-side replay viewer, embedded verbatim into the HTML report by
3
+ // html-index.js (readFileSync at render time — file://-safe, no fetch).
4
+ //
5
+ // Contract: window.initChReplayViewer(mountEl, model) where model is the
6
+ // viewer model emitted by replay-assets.js (trial-relative event times,
7
+ // per-trial camera seeds, marker attribute name, legacy flag).
8
+ //
9
+ // Coordinate model (cursor-alignment fix):
10
+ // The stage shows the RECORD-TIME VIEWPORT, not the document. A camera
11
+ // state (window scroll + viewport size) is maintained at the playhead from
12
+ // the trial's seed + its scroll/resize events; the iframe is sized to the
13
+ // record-time layout (clientWidth/Height), scrolled to the record-time
14
+ // scroll, and letterboxed into a fixed stage box. Cursor/trail draw in
15
+ // CLIENT space: new recordings carry cx/cy per event; legacy recordings
16
+ // re-project page coords minus the running scroll (reduced guarantees,
17
+ // surfaced by a persistent banner).
18
+ //
19
+ // Every anchored interaction runs an alignment self-check (marker
20
+ // resolution + camera assertions + recorded-vs-replayed rect + containment
21
+ // + hit-test). Failures NEVER stay silent: header status, lane marks, an
22
+ // uncertain cursor glyph, and a terminated trail.
23
+ //
24
+ // Anatomy per mount:
25
+ // header tier badge · trial <select> · play/pause · speed · clock · status chips
26
+ // stage tier 'dom': sandboxed <iframe srcdoc> rebuilt per trial/seek
27
+ // tier 'trace': neutral gray stage with a label
28
+ // + <canvas> overlay (cursor trail, click ripples)
29
+ // lane per-trial marker lane (clipboard/mutations/bands/check marks)
30
+ // scrub <input type=range> in ms, wired both ways with the play loop
31
+ // ticker text line of the events nearest the playhead
32
+
33
+ (function () {
34
+ 'use strict';
35
+
36
+ var MARKER_COLORS = {
37
+ paste: '#d32f2f', 'ch:drop': '#7b1fa2', copy: '#f57c00', cut: '#f57c00',
38
+ 'ch:guard_violation': '#d32f2f', 'ch:capture_stopped': '#616161',
39
+ mutation: '#1976d2', input: '#388e3c'
40
+ };
41
+ var UNCERTAIN_COLOR = '#b26a00';
42
+
43
+ // Self-check tolerances (CSS px, unscaled): camera ±1; rect edges ±3
44
+ // conjunctive with containment (2px allowance) AND hit-test; stage
45
+ // transform ±2.
46
+ var TOL_CAMERA = 1;
47
+ var TOL_RECT = 3;
48
+ var TOL_CONTAIN = 2;
49
+ var TOL_STAGE = 2;
50
+
51
+ // Keycast: how long a chip keeps fading after its keyup, in trial-relative
52
+ // ms. Purely a function of playhead (like the click-ripple fade in
53
+ // drawOverlay below), not real time, so seeking lands on the same visual
54
+ // state playing to it would.
55
+ var KEYCAST_FADE_MS = 500;
56
+
57
+ // Buffer-cap explanation fallback: recorder.js's REPLAY_DEFAULTS, used
58
+ // only when a stopped trial's own ch:capture_stopped event (the real
59
+ // configured value for THIS recording) can't be found.
60
+ var CAP_DEFAULTS = { events: 50000, chars: 8000000 };
61
+
62
+ var DISCRETE_KINDS = {
63
+ mousedown: true, mouseup: true, click: true, touchstart: true, touchend: true
64
+ };
65
+
66
+ function el(tag, cls, text) {
67
+ var node = document.createElement(tag);
68
+ if (cls) node.className = cls;
69
+ if (text != null) node.textContent = text;
70
+ return node;
71
+ }
72
+
73
+ function fmtClock(ms) {
74
+ var s = Math.max(0, ms) / 1000;
75
+ var m = Math.floor(s / 60);
76
+ var rem = (s - m * 60).toFixed(1);
77
+ return m + ':' + (rem < 10 ? '0' : '') + rem;
78
+ }
79
+
80
+ // Human label for a keycast chip. e.key already names special keys well
81
+ // (Enter, Shift, Backspace, ArrowLeft…); the one gap is the space bar,
82
+ // which arrives as a literal ' ' and would render as an invisible chip.
83
+ function formatKeyLabel(key) {
84
+ return key === ' ' ? 'Space' : key;
85
+ }
86
+
87
+ function fmtCount(n) {
88
+ return n.toLocaleString('en-US');
89
+ }
90
+
91
+ // ── DOM reconstruction (tier 'dom') ──
92
+ // The recorded DOM is UNTRUSTED (an adversarial participant or extension
93
+ // may have injected markup). sandbox="" already blocks scripts; the CSP
94
+ // meta below additionally kills frames, form posts, fetch/XHR-carrying
95
+ // elements, and plugin content, while still allowing images/media/styles
96
+ // and fonts — the carriers of visual fidelity. Residual (documented):
97
+ // a participant-injected <img src> can still fire a GET to its host when
98
+ // the analyst loads the replay. no-referrer strips the analyst context.
99
+ function srcdocCsp(allowExternalCss) {
100
+ return "default-src 'none'; img-src * data: blob:; media-src * data: blob:; " +
101
+ (allowExternalCss ? "style-src 'unsafe-inline' https: http:; " : "style-src 'unsafe-inline'; ") +
102
+ "font-src * data:; " +
103
+ "form-action 'none'; frame-src 'none'; connect-src 'none'; base-uri 'none';";
104
+ }
105
+
106
+ function attrEscape(s) {
107
+ return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
108
+ }
109
+
110
+ function buildSrcdoc(model, trial, allowExternalCss) {
111
+ var styles = '';
112
+ (model.stylesheets || []).forEach(function (sheet) {
113
+ if (sheet.css) {
114
+ styles += '<style>' + sheet.css.replace(/<\//g, '<\\/') + '</style>';
115
+ } else if (allowExternalCss && sheet.href && /^https?:/i.test(sheet.href)) {
116
+ styles += '<link rel="stylesheet" href="' + attrEscape(sheet.href) + '">';
117
+ }
118
+ });
119
+ // NOTE deliberately ABSENT: the old `html,body{margin:0}` override. It
120
+ // came AFTER the captured CSS and silently overrode the page's own body
121
+ // margins (including `margin:auto` centering) — the single largest
122
+ // cursor-misalignment source on the acceptance fixture (~390px). The
123
+ // reconstruction must lay out exactly as the recorded page did, UA
124
+ // defaults included.
125
+ // The frame is sized to the RECORD-TIME layout width (clientWidth =
126
+ // innerWidth minus any classic scrollbar), so the frame's own root
127
+ // scrollbar must not subtract a second gutter on classic-scrollbar
128
+ // platforms. Hiding the ROOT scroller's bar (scrolling still works
129
+ // programmatically) keeps layout width == camera width everywhere;
130
+ // inner containers keep their bars.
131
+ var gutterFix = '<style>html{scrollbar-width:none}' +
132
+ 'html::-webkit-scrollbar{width:0;height:0}</style>';
133
+ return '<!DOCTYPE html><html><head><meta charset="utf-8">' +
134
+ '<meta http-equiv="Content-Security-Policy" content="' + srcdocCsp(allowExternalCss) + '">' +
135
+ '<meta name="referrer" content="no-referrer">' + gutterFix + styles +
136
+ '</head><body>' + (trial.initialDom || '') + '</body></html>';
137
+ }
138
+
139
+ function resolvePath(root, path) {
140
+ if (!path) return null;
141
+ var cur = root;
142
+ for (var i = 0; i < path.length; i++) {
143
+ if (!cur || !cur.childNodes) return null;
144
+ cur = cur.childNodes[path[i]];
145
+ }
146
+ return cur || null;
147
+ }
148
+
149
+ window.initChReplayViewer = function (mount, model) {
150
+ if (!mount || !model) return;
151
+ if (mount._chReplayInit) return; // double-click guard
152
+ mount._chReplayInit = true;
153
+ mount.textContent = '';
154
+
155
+ var trials = model.trials || [];
156
+ if (trials.length === 0) {
157
+ mount.appendChild(el('p', 'replay-note', 'Recording contains no trials.'));
158
+ return;
159
+ }
160
+ // Session scrollbar delta for legacy resize events (see buildViewerModel).
161
+ var sbW = (model.scrollbar && model.scrollbar.w) || 0;
162
+ var sbH = (model.scrollbar && model.scrollbar.h) || 0;
163
+
164
+ // ── State ──
165
+ var trialIdx = 0;
166
+ var playhead = 0; // trial-relative ms
167
+ var playing = false;
168
+ var speed = 1;
169
+ // Continuous whole-session playback, default ON: at a trial's end while
170
+ // playing, load the next trial and keep going, so an analyst watches a
171
+ // full session as one video instead of stopping at every boundary. The
172
+ // 'pause at trial boundaries' toggle flips this off, restoring the
173
+ // original per-trial-stop behavior.
174
+ var autoAdvance = true;
175
+ var appliedIdx = 0; // events applied so far (in trial event order)
176
+ var iframeGen = 0; // increments per rebuild; stale onloads bail
177
+ var domReady = false;
178
+ var markerMap = new Map(); // marker id (string) → element, per frame gen
179
+ var counters = { patchFailures: 0, scrollFailures: 0 };
180
+ // Camera at the applied position: window scroll + viewport dims.
181
+ var cam = null; // {x, y, w, h, cw, ch}
182
+ var pendingCamSize = false; // viewport dims changed since last flush
183
+ var stageW = 0, stageH = 0; // stage box (fixed per trial)
184
+ var k = 1, ox = 0, oy = 0; // iframe scale + letterbox origin
185
+
186
+ function trial() { return trials[trialIdx]; }
187
+
188
+ function seedCamera() {
189
+ var seed = trial().camera || {};
190
+ cam = {
191
+ x: seed.x || 0, y: seed.y || 0,
192
+ w: seed.w || 1280, h: seed.h || 800,
193
+ cw: seed.cw || seed.w || 1280, ch: seed.ch || seed.h || 800
194
+ };
195
+ }
196
+
197
+ // ── Header ──
198
+ var header = el('div', 'replay-header');
199
+ var badge = el('span', 'replay-badge', model.tier === 'dom' ? 'DOM replay' : 'Trace replay');
200
+ badge.setAttribute('data-tier', model.tier === 'dom' ? 'dom' : 'trace');
201
+ badge.setAttribute('role', 'status');
202
+ var trialSel = el('select', 'replay-trial-select');
203
+ trials.forEach(function (t, i) {
204
+ var opt = el('option', null,
205
+ 'Trial ' + (t.index != null ? t.index : i) + ' — ' + (t.id || '?') +
206
+ ' (' + fmtClock(t.durMs) + ')');
207
+ opt.value = String(i);
208
+ trialSel.appendChild(opt);
209
+ });
210
+ var playBtn = el('button', 'replay-play', '▶');
211
+ playBtn.setAttribute('aria-label', 'Play');
212
+ var speedSel = el('select', 'replay-speed');
213
+ [1, 2, 4, 8].forEach(function (s) {
214
+ var o = el('option', null, s + '×'); o.value = String(s); speedSel.appendChild(o);
215
+ });
216
+ var clock = el('span', 'replay-clock', '0:00.0 / 0:00.0');
217
+ var sessionPos = el('span', 'replay-note replay-session-pos', '');
218
+ var pauseLabel = el('label', 'replay-pause-toggle');
219
+ var pauseCheckbox = document.createElement('input');
220
+ pauseCheckbox.type = 'checkbox';
221
+ pauseCheckbox.className = 'replay-pause-checkbox';
222
+ pauseCheckbox.checked = false; // default: continuous playback ON
223
+ pauseCheckbox.addEventListener('change', function () {
224
+ autoAdvance = !pauseCheckbox.checked;
225
+ });
226
+ pauseLabel.appendChild(pauseCheckbox);
227
+ pauseLabel.appendChild(document.createTextNode(' Pause at trial boundaries'));
228
+ header.appendChild(badge);
229
+ header.appendChild(trialSel);
230
+ header.appendChild(playBtn);
231
+ header.appendChild(speedSel);
232
+ header.appendChild(clock);
233
+ header.appendChild(sessionPos);
234
+ if (trials.length > 1) header.appendChild(pauseLabel);
235
+ if (!model.scoring) {
236
+ header.appendChild(el('span', 'replay-note',
237
+ 'No integrity score attached (this recording was captured standalone)'));
238
+ }
239
+ if (model.captureStopped) {
240
+ // The real configured caps for THIS recording live on the trial's own
241
+ // ch:capture_stopped marker (recorder.js writes it with the limit that
242
+ // was actually crossed) — read those instead of assuming the library
243
+ // defaults, since maxEventsPerTrial/maxCharsPerTrial are configurable.
244
+ var capEventsLimit = null, capCharsLimit = null, capTrialsHit = 0;
245
+ trials.forEach(function (t) {
246
+ var hit = false;
247
+ (t.events || []).forEach(function (e) {
248
+ if (e.kind !== 'ch:capture_stopped') return;
249
+ hit = true;
250
+ if (e.limit != null && capEventsLimit == null) capEventsLimit = e.limit;
251
+ if (e.limit_chars != null && capCharsLimit == null) capCharsLimit = e.limit_chars;
252
+ });
253
+ if (hit) capTrialsHit++;
254
+ });
255
+ if (capEventsLimit == null) capEventsLimit = CAP_DEFAULTS.events;
256
+ if (capCharsLimit == null) capCharsLimit = CAP_DEFAULTS.chars;
257
+ var capTrialsCount = capTrialsHit || 1;
258
+ var capDetails = document.createElement('details');
259
+ capDetails.className = 'replay-note replay-warn replay-cap';
260
+ capDetails.setAttribute('data-ch-cap-note', '');
261
+ var capSummary = document.createElement('summary');
262
+ capSummary.textContent = 'Capture stopped early in ' + capTrialsCount +
263
+ ' trial' + (capTrialsCount === 1 ? '' : 's') + ' (buffer cap, details)';
264
+ var capBody = el('p', null,
265
+ 'The recorder caps each trial at ' + fmtCount(capEventsLimit) + ' events or about ' +
266
+ fmtCount(capCharsLimit) + ' characters, whichever comes first (the character count ' +
267
+ 'starts from that trial’s initial DOM snapshot). Once a trial crosses either cap, ' +
268
+ 'that trial stops recording and a marker is written; the next trial starts capturing ' +
269
+ 'fresh. The caps limit the damage one runaway trial can do. They do not bound the whole ' +
270
+ 'session: a recording with many trials can still add up to a large file. Both caps are ' +
271
+ 'configurable when the recorder is attached (maxEventsPerTrial, maxCharsPerTrial).');
272
+ capDetails.appendChild(capSummary);
273
+ capDetails.appendChild(capBody);
274
+ header.appendChild(capDetails);
275
+ }
276
+ // Alignment status chips — the "never fail silently" surface.
277
+ var alignChip = el('span', 'replay-note', '');
278
+ alignChip.setAttribute('role', 'status');
279
+ alignChip.setAttribute('data-ch-align', '');
280
+ header.appendChild(alignChip);
281
+ if (model.legacy) {
282
+ var legacyChip = el('span', 'replay-note replay-warn',
283
+ 'Legacy recording: alignment guarantees are reduced (coordinates re-projected, unverified)');
284
+ legacyChip.setAttribute('data-ch-legacy', '');
285
+ header.appendChild(legacyChip);
286
+ }
287
+ var iframeChip = el('span', 'replay-note replay-warn',
288
+ 'This recording contains iframe content that was not captured. Interactions inside it are not visible here.');
289
+ iframeChip.setAttribute('data-ch-iframe-warn', '');
290
+ iframeChip.style.display = 'none';
291
+ header.appendChild(iframeChip);
292
+ var shadowChip = el('span', 'replay-note replay-warn',
293
+ 'This recording contains shadow DOM content that was not captured. Those areas replay empty.');
294
+ shadowChip.setAttribute('data-ch-shadow-warn', '');
295
+ shadowChip.style.display = 'none';
296
+ header.appendChild(shadowChip);
297
+ var failChip = el('span', 'replay-note replay-warn', '');
298
+ failChip.setAttribute('data-ch-apply-failures', '');
299
+ failChip.style.display = 'none';
300
+ header.appendChild(failChip);
301
+ // Declared-limitation advisories (loud, not blocking): DPR-conditional
302
+ // styling cannot be reproduced inside an iframe; CSS animations run on
303
+ // viewer time. Both set per trial / model.
304
+ var dprChip = el('span', 'replay-note', '');
305
+ dprChip.setAttribute('data-ch-dpr-note', '');
306
+ dprChip.style.display = 'none';
307
+ header.appendChild(dprChip);
308
+ var animChip = el('span', 'replay-note', '');
309
+ animChip.setAttribute('data-ch-anim-note', '');
310
+ animChip.style.display = 'none';
311
+ var hasKeyframes = (model.stylesheets || []).some(function (sh) {
312
+ return sh.css && /@keyframes/i.test(sh.css);
313
+ });
314
+ if (hasKeyframes) {
315
+ animChip.textContent = 'This page uses CSS animations. They replay on the viewer’s own clock, so animated positions are approximate.';
316
+ animChip.style.display = '';
317
+ }
318
+ header.appendChild(animChip);
319
+
320
+ var allowExternalCss = false;
321
+ var omittedSheets = (model.stylesheets || []).filter(function (sh) { return !sh.css; }).length;
322
+ if (omittedSheets > 0) {
323
+ var cssNote = el('span', 'replay-note',
324
+ omittedSheets + ' external stylesheet(s) were not loaded. Layout may differ. ');
325
+ var cssBtn = el('button', 'replay-css-btn', 'Load external CSS');
326
+ cssBtn.title = 'Fetches the recorded stylesheet URLs from their origins (network requests leave this machine).';
327
+ cssBtn.addEventListener('click', function () {
328
+ allowExternalCss = true;
329
+ cssBtn.remove();
330
+ cssNote.textContent = omittedSheets + ' external stylesheet(s) loaded from their origins.';
331
+ if (iframe) rebuildFrame(playhead);
332
+ });
333
+ cssNote.appendChild(cssBtn);
334
+ header.appendChild(cssNote);
335
+ }
336
+ mount.appendChild(header);
337
+
338
+ // ── Stage ──
339
+ var stage = el('div', 'replay-stage');
340
+ var iframe = null;
341
+ if (model.tier === 'dom') {
342
+ iframe = document.createElement('iframe');
343
+ // allow-same-origin WITHOUT allow-scripts: scripts stay blocked (both
344
+ // by the sandbox and by the srcdoc CSP), but the document keeps a
345
+ // reachable origin so the player can apply mutation/input state via
346
+ // contentDocument. sandbox="" would make the origin opaque and freeze
347
+ // every dom-tier replay at its first frame.
348
+ iframe.setAttribute('sandbox', 'allow-same-origin');
349
+ iframe.className = 'replay-frame';
350
+ // Non-interactive: an analyst click inside the reconstruction would
351
+ // act on live untrusted DOM (submit a form, navigate the frame).
352
+ iframe.style.pointerEvents = 'none';
353
+ iframe.setAttribute('tabindex', '-1');
354
+ iframe.style.transformOrigin = '0 0';
355
+ stage.appendChild(iframe);
356
+ } else {
357
+ stage.classList.add('replay-neutral');
358
+ stage.appendChild(el('div', 'replay-neutral-label',
359
+ 'Trace-only recording: DOM was not captured at this tier'));
360
+ }
361
+
362
+ var overlay = document.createElement('canvas');
363
+ overlay.className = 'replay-overlay';
364
+ overlay.setAttribute('aria-hidden', 'true');
365
+ stage.appendChild(overlay);
366
+ // Keycast: chips for keys currently down or recently released, mirroring
367
+ // the click-ripple/cursor-trail convention above (a pure function of
368
+ // playhead, drawn alongside them — see drawKeycast). keys:'off'
369
+ // recordings carry no keydown/keyup events, so this stays empty; that
370
+ // absence IS the honest signal, no separate note needed.
371
+ var keycast = el('div', 'replay-keycast');
372
+ keycast.setAttribute('aria-hidden', 'true');
373
+ stage.appendChild(keycast);
374
+ mount.appendChild(stage);
375
+ var ctx = overlay.getContext('2d');
376
+
377
+ // ── Marker lane + scrub ──
378
+ var lane = document.createElement('canvas');
379
+ lane.className = 'replay-lane';
380
+ lane.height = 18;
381
+ lane.setAttribute('aria-hidden', 'true');
382
+ mount.appendChild(lane);
383
+
384
+ var scrub = document.createElement('input');
385
+ scrub.type = 'range';
386
+ scrub.min = '0';
387
+ scrub.step = '10';
388
+ scrub.className = 'replay-scrub';
389
+ scrub.setAttribute('aria-label', 'Playhead position');
390
+ mount.appendChild(scrub);
391
+
392
+ var ticker = el('div', 'replay-ticker', '');
393
+ mount.appendChild(ticker);
394
+
395
+ // ── Stage geometry ──
396
+ // The stage BOX is fixed per trial (sized from the trial's seed camera
397
+ // aspect) so playback never reflows the report page; camera changes
398
+ // re-letterbox the iframe INSIDE the box.
399
+ function sizeStage() {
400
+ stageW = Math.min(mount.clientWidth || 720, 960);
401
+ var seed = trial().camera || {};
402
+ var scw = seed.cw || seed.w || 1280;
403
+ var sch = seed.ch || seed.h || 800;
404
+ stageH = Math.round(sch * (stageW / scw));
405
+ stage.style.width = stageW + 'px';
406
+ stage.style.height = stageH + 'px';
407
+ overlay.width = stageW;
408
+ overlay.height = stageH;
409
+ lane.width = stageW;
410
+ scrub.style.width = stageW + 'px';
411
+ }
412
+
413
+ // Apply the current camera's viewport dims to the iframe + transform.
414
+ // Called lazily: before each anchored self-check and once per applied
415
+ // batch — a 240-event resize storm costs a handful of real reflows, not
416
+ // 240 (Sol round-1 finding 11).
417
+ function flushCamSize() {
418
+ pendingCamSize = false;
419
+ // Scale + letterbox apply to the OVERLAY too — trace-tier replays have
420
+ // no iframe but still project the cursor onto the scaled stage.
421
+ k = Math.min(stageW / cam.cw, stageH / cam.ch);
422
+ ox = Math.round((stageW - cam.cw * k) / 2);
423
+ oy = Math.round((stageH - cam.ch * k) / 2);
424
+ if (!iframe) return;
425
+ var wpx = cam.cw + 'px', hpx = cam.ch + 'px';
426
+ if (iframe.style.width !== wpx) iframe.style.width = wpx;
427
+ if (iframe.style.height !== hpx) iframe.style.height = hpx;
428
+ var tf = 'translate(' + ox + 'px,' + oy + 'px) scale(' + k + ')';
429
+ if (iframe.style.transform !== tf) iframe.style.transform = tf;
430
+ }
431
+
432
+ function applyCamScroll() {
433
+ if (!iframe || !domReady) return;
434
+ var doc = iframe.contentDocument;
435
+ if (doc && doc.defaultView && typeof doc.defaultView.scrollTo === 'function') {
436
+ try { doc.defaultView.scrollTo(cam.x, cam.y); } catch (e) { /* best-effort */ }
437
+ }
438
+ }
439
+
440
+ // ── Marker harvesting ──
441
+ // Markers arrive as data-chn-<nonce> attributes in the serialized HTML.
442
+ // They are harvested into an out-of-band Map and REMOVED immediately, so
443
+ // no marker attribute ever participates in a layout we measure and page
444
+ // CSS/attributes can never hijack resolution.
445
+ function harvestMarkers(scope) {
446
+ if (!model.markerAttr || !scope || !scope.querySelectorAll) return;
447
+ var found;
448
+ try { found = scope.querySelectorAll('[' + model.markerAttr + ']'); }
449
+ catch (e) { return; }
450
+ for (var i = 0; i < found.length; i++) {
451
+ var node = found[i];
452
+ markerMap.set(node.getAttribute(model.markerAttr), node);
453
+ node.removeAttribute(model.markerAttr);
454
+ }
455
+ if (scope.getAttribute && scope.getAttribute(model.markerAttr) != null) {
456
+ markerMap.set(scope.getAttribute(model.markerAttr), scope);
457
+ scope.removeAttribute(model.markerAttr);
458
+ }
459
+ }
460
+
461
+ // LATCHED per trial selection: an iframe (or shadow host) that existed at
462
+ // ANY point in the trial keeps its warning even after a removal patch —
463
+ // the invisible interactions don't become visible because the frame left.
464
+ var trialHadIframe = false;
465
+ var trialHadShadow = false;
466
+ function updateIframeWarning(doc) {
467
+ try {
468
+ if (doc && doc.querySelector('[data-ch-iframe]')) trialHadIframe = true;
469
+ if (doc && doc.querySelector('[data-ch-shadow]')) trialHadShadow = true;
470
+ } catch (e) { /* keep latches */ }
471
+ iframeChip.style.display = trialHadIframe ? '' : 'none';
472
+ shadowChip.style.display = trialHadShadow ? '' : 'none';
473
+ }
474
+
475
+ // Resolve a mutation/anchor reference: marker first (validated by tag +
476
+ // connectedness), then the legacy child-index path, then raw id. The
477
+ // path fallback is TAG-VALIDATED whenever the reference carries an
478
+ // expected tag: a failed marker means the reconstruction diverged (e.g.
479
+ // the parser dropped an invalid-but-real construct like nested forms),
480
+ // and an unvalidated raw-index path would silently patch whatever node
481
+ // now sits at those indices. Legacy references (no tag) keep the
482
+ // original unvalidated behavior.
483
+ function resolveRef(doc, ref) {
484
+ if (ref.n != null) {
485
+ var byMarker = markerMap.get(String(ref.n));
486
+ if (byMarker && byMarker.isConnected &&
487
+ (!ref.tag || byMarker.tagName.toLowerCase() === ref.tag)) {
488
+ return byMarker;
489
+ }
490
+ }
491
+ var byPath = resolvePath(doc.body, ref.path);
492
+ if (byPath && (!ref.tag ||
493
+ (byPath.tagName && byPath.tagName.toLowerCase() === ref.tag))) {
494
+ return byPath;
495
+ }
496
+ if (ref.id && doc.getElementById) {
497
+ var byId = doc.getElementById(ref.id);
498
+ if (byId && (!ref.tag || byId.tagName.toLowerCase() === ref.tag)) return byId;
499
+ }
500
+ return null;
501
+ }
502
+
503
+ // ── DOM state at playhead ──
504
+ function rebuildFrame(thenSeekTo, isRevalidation) {
505
+ if (!iframe) return;
506
+ iframeGen++;
507
+ var gen = iframeGen;
508
+ domReady = false;
509
+ appliedIdx = 0;
510
+ markerMap = new Map();
511
+ counters.patchFailures = 0;
512
+ counters.scrollFailures = 0;
513
+ seedCamera();
514
+ pendingCamSize = true;
515
+ // Reset check caches — they are recomputed deterministically during
516
+ // forward re-application.
517
+ trial().events.forEach(function (e) { if (e.__chk) delete e.__chk; });
518
+ iframe.onload = function () {
519
+ if (gen !== iframeGen) return; // superseded by a newer rebuild
520
+ domReady = true;
521
+ var doc = iframe.contentDocument;
522
+ harvestMarkers(doc && doc.body);
523
+ updateIframeWarning(doc);
524
+ flushCamSize();
525
+ applyCamScroll();
526
+ applyDomUpTo(thenSeekTo);
527
+ drawOverlay();
528
+ drawKeycast();
529
+ updateStatusChips();
530
+ // Late-layout revalidation: web fonts settling after the first
531
+ // layout can shift geometry and stale an initially-passing check.
532
+ // One revalidation pass per user-initiated rebuild (guarded against
533
+ // rebuild→fonts.ready→rebuild loops).
534
+ if (!isRevalidation && doc && doc.fonts && doc.fonts.ready &&
535
+ typeof doc.fonts.ready.then === 'function') {
536
+ doc.fonts.ready.then(function () {
537
+ if (gen === iframeGen) rebuildFrame(playhead, true);
538
+ }).catch(function () { /* fonts API absent/failed — skip */ });
539
+ }
540
+ };
541
+ iframe.srcdoc = buildSrcdoc(model, trial(), allowExternalCss);
542
+ }
543
+
544
+ // Restores form state from an 'input' event. Form values are element
545
+ // PROPERTIES — mutation snapshots never carry them — so without this
546
+ // step every replayed form would look untouched.
547
+ function applyInput(doc, e) {
548
+ var target = null;
549
+ var wantTag = e.el ? String(e.el).split('#')[0].toUpperCase() : null;
550
+ // Marker first: getElementById returns the FIRST duplicate, which can
551
+ // restore the typed value into the wrong control silently.
552
+ if (e.n != null) {
553
+ target = resolveRef(doc, {
554
+ n: e.n, tag: wantTag ? wantTag.toLowerCase() : null, path: null, id: null
555
+ });
556
+ }
557
+ if (!target && e.id) {
558
+ var byId = doc.getElementById(e.id);
559
+ if (byId && (!wantTag || byId.tagName === wantTag)) target = byId;
560
+ }
561
+ if (!target && e.el) {
562
+ try { target = doc.querySelector(e.el); } catch (err) { /* bad selector */ }
563
+ }
564
+ if (!target) return;
565
+ if (e.redacted) {
566
+ target.value = new Array((e.value_len || 0) + 1).join('•');
567
+ } else if (e.value != null) {
568
+ target.value = e.value;
569
+ }
570
+ if (e.checked != null) target.checked = e.checked;
571
+ }
572
+
573
+ function applyMutation(doc, patch) {
574
+ var target = resolveRef(doc, patch);
575
+ if (!target) { counters.patchFailures++; return; }
576
+ if (patch.op === 'childList') {
577
+ target.innerHTML = patch.html;
578
+ harvestMarkers(target);
579
+ updateIframeWarning(doc);
580
+ } else if (patch.op === 'attributes') {
581
+ // Refuse event-handler and malformed attribute names even if an
582
+ // artifact (older recorder, third-party #3661 file) carries them.
583
+ if (!/^[a-zA-Z_:][-a-zA-Z0-9_:.]*$/.test(patch.name) || /^on/i.test(patch.name)) return;
584
+ if (patch.value === null) target.removeAttribute && target.removeAttribute(patch.name);
585
+ else target.setAttribute && target.setAttribute(patch.name, patch.value);
586
+ } else if (patch.op === 'characterData') {
587
+ target.textContent = patch.value;
588
+ }
589
+ }
590
+
591
+ // Element scroll restore (window scrolls are camera state, handled in
592
+ // the application loop). Redacted scrolls are unresolvable BY DESIGN.
593
+ function applyElementScroll(doc, e) {
594
+ if (e.redacted) return;
595
+ var target = null;
596
+ if (e.n != null || e.id) {
597
+ target = resolveRef(doc, { n: e.n, id: e.id, tag: null, path: null });
598
+ }
599
+ if (!target && e.el) {
600
+ try { target = doc.querySelector(e.el); } catch (err) { /* bad selector */ }
601
+ }
602
+ if (!target) { counters.scrollFailures++; return; }
603
+ try { target.scrollTop = e.y || 0; target.scrollLeft = e.x || 0; }
604
+ catch (err) { counters.scrollFailures++; }
605
+ }
606
+
607
+ // ── Alignment self-check (per anchored discrete event) ──
608
+ // Compares INDEPENDENT coordinate systems (Sol round-2 finding 10):
609
+ // camera assertions — applied scroll/dims vs requested (±1px)
610
+ // rect — recorded client rect vs replayed rect (±3px/edge)
611
+ // containment — projected point inside replayed rect (2px)
612
+ // hit-test — elementFromPoint resolves the anchor family
613
+ // stage transform — iframe's REAL on-page offset vs computed (±2px)
614
+ // Any failed predicate ⇒ status 'uncertain' with reasons, surfaced by
615
+ // chips + lane + glyph. Redacted anchors are 'redacted' (unverifiable).
616
+ function evaluateCheck(e) {
617
+ if (!iframe || !domReady) return;
618
+ var doc = iframe.contentDocument;
619
+ if (!doc) return;
620
+ var t = e.target;
621
+ if (t.redacted) { e.__chk = { status: 'redacted', reasons: ['redacted target'] }; return; }
622
+ var reasons = [];
623
+ // Event-time shadow retargeting (recorder saw a composed path deeper
624
+ // than e.target): the visible thing that was clicked is not in the
625
+ // reconstruction, whatever the host's geometry says. Latches the
626
+ // trial-level warning too — a post-snapshot attachShadow() leaves no
627
+ // serialized marker for updateIframeWarning to find.
628
+ if (t.shadow) {
629
+ reasons.push('shadow content not captured');
630
+ trialHadShadow = true;
631
+ updateIframeWarning(doc);
632
+ }
633
+
634
+ // camera assertions
635
+ var view = doc.defaultView;
636
+ if (view) {
637
+ if (Math.abs((view.scrollX || 0) - cam.x) > TOL_CAMERA ||
638
+ Math.abs((view.scrollY || 0) - cam.y) > TOL_CAMERA) {
639
+ reasons.push('applied scroll diverges (' +
640
+ (view.scrollX || 0) + ',' + (view.scrollY || 0) + ' vs ' + cam.x + ',' + cam.y + ')');
641
+ }
642
+ var de = doc.documentElement;
643
+ if (de && Math.abs(de.clientWidth - cam.cw) > TOL_CAMERA) {
644
+ reasons.push('frame layout width ' + de.clientWidth + ' vs camera ' + cam.cw);
645
+ }
646
+ }
647
+
648
+ // anchor resolution
649
+ var target = null;
650
+ if (t.n != null || t.id) {
651
+ target = resolveRef(doc, { n: t.n, id: t.id, tag: t.tag, path: null });
652
+ }
653
+ if (!target) {
654
+ reasons.push('anchor unresolved (' + (t.tag || '?') + (t.id ? '#' + t.id : '') + ')');
655
+ } else if (target.hasAttribute && target.hasAttribute('data-ch-shadow')) {
656
+ // The interaction retargeted to a shadow host whose content was not
657
+ // captured — geometry would "verify" against a hollow box. Refuse.
658
+ reasons.push('shadow content not captured');
659
+ } else {
660
+ var rr = null;
661
+ try { rr = target.getBoundingClientRect(); } catch (err) { /* leave null */ }
662
+ // recorded vs replayed rect, all four edges (conjunctive)
663
+ if (rr && t.rect) {
664
+ var dL = Math.abs(rr.left - t.rect[0]);
665
+ var dT = Math.abs(rr.top - t.rect[1]);
666
+ var dR = Math.abs((rr.left + rr.width) - (t.rect[0] + t.rect[2]));
667
+ var dB = Math.abs((rr.top + rr.height) - (t.rect[1] + t.rect[3]));
668
+ if (dL > TOL_RECT || dT > TOL_RECT || dR > TOL_RECT || dB > TOL_RECT) {
669
+ reasons.push('target rect moved (Δ ' +
670
+ Math.round(Math.max(dL, dT, dR, dB)) + 'px)');
671
+ }
672
+ }
673
+ // containment of the projected point
674
+ var px = e.cx, py = e.cy;
675
+ if (px == null) { px = (e.x || 0) - cam.x; py = (e.y || 0) - cam.y; }
676
+ if (rr && !(px >= rr.left - TOL_CONTAIN && px <= rr.left + rr.width + TOL_CONTAIN &&
677
+ py >= rr.top - TOL_CONTAIN && py <= rr.top + rr.height + TOL_CONTAIN)) {
678
+ reasons.push('cursor outside target');
679
+ }
680
+ // independent hit-test
681
+ if (doc.elementFromPoint && px != null) {
682
+ var under = null;
683
+ try { under = doc.elementFromPoint(px, py); } catch (err) { /* skip */ }
684
+ if (under && under !== target &&
685
+ !(target.contains && target.contains(under)) &&
686
+ !(under.contains && under.contains(target))) {
687
+ reasons.push('hit-test resolves ' +
688
+ (under.id ? under.tagName.toLowerCase() + '#' + under.id : under.tagName.toLowerCase()));
689
+ }
690
+ }
691
+ }
692
+
693
+ // stage transform: the iframe's REAL rendered offset must match the
694
+ // computed letterbox (catches viewer CSS/transform regressions).
695
+ try {
696
+ var ir = iframe.getBoundingClientRect();
697
+ var sr = stage.getBoundingClientRect();
698
+ if (Math.abs(ir.left - sr.left - ox) > TOL_STAGE ||
699
+ Math.abs(ir.top - sr.top - oy) > TOL_STAGE) {
700
+ reasons.push('stage transform drift');
701
+ }
702
+ } catch (err) { /* measuring failed — not evidence of misalignment */ }
703
+
704
+ e.__chk = reasons.length > 0
705
+ ? { status: 'uncertain', reasons: reasons }
706
+ : { status: 'ok', reasons: [] };
707
+ }
708
+
709
+ // Applies DOM patches AND folds camera state up to T. The camera fold
710
+ // runs at BOTH tiers — trace-tier replays have no reconstruction but the
711
+ // cursor projection still needs scroll/resize/snapshot state and the
712
+ // stage transform. DOM operations are conditional on `doc`.
713
+ function applyDomUpTo(T) {
714
+ var doc = null;
715
+ if (iframe) {
716
+ if (!domReady) return;
717
+ doc = iframe.contentDocument;
718
+ if (!doc) return;
719
+ }
720
+ var events = trial().events;
721
+ for (; appliedIdx < events.length; appliedIdx++) {
722
+ var e = events[appliedIdx];
723
+ if (e.t > T) break;
724
+ if (e.kind === 'mutation') {
725
+ if (doc) applyMutation(doc, e);
726
+ } else if (e.kind === 'input') {
727
+ if (doc) applyInput(doc, e);
728
+ } else if (e.kind === 'scroll') {
729
+ if (e.el != null || e.redacted) {
730
+ if (doc) applyElementScroll(doc, e);
731
+ } else {
732
+ cam.x = e.x || 0; cam.y = e.y || 0;
733
+ applyCamScroll();
734
+ }
735
+ } else if (e.kind === 'resize') {
736
+ cam.w = e.w != null ? e.w : cam.w;
737
+ cam.h = e.h != null ? e.h : cam.h;
738
+ // Layout dims: recorded cw/ch when present; legacy events fall
739
+ // back to the embedded visualViewport width, then to w minus the
740
+ // session scrollbar delta — the SAME chain buildViewerModel uses
741
+ // for cross-trial folding, so in-trial state can't diverge from
742
+ // the seeds.
743
+ cam.cw = e.cw != null ? e.cw
744
+ : (e.vv && e.vv.width != null ? e.vv.width
745
+ : (e.w != null ? e.w - sbW : cam.cw));
746
+ cam.ch = e.ch != null ? e.ch
747
+ : (e.vv && e.vv.height != null ? e.vv.height
748
+ : (e.h != null ? e.h - sbH : cam.ch));
749
+ pendingCamSize = true; // folded — applied lazily, not per event
750
+ } else if (DISCRETE_KINDS[e.kind]) {
751
+ // Camera snapshot on the interaction is AUTHORITATIVE: scroll and
752
+ // resize notifications dispatch asynchronously after programmatic
753
+ // scrolls, so the snapshot may know state whose notification event
754
+ // arrives later in the stream (which then applies as a no-op).
755
+ if (e.sx != null) {
756
+ if (e.sx !== cam.x || e.sy !== cam.y) {
757
+ cam.x = e.sx; cam.y = e.sy;
758
+ applyCamScroll();
759
+ }
760
+ if (e.cw != null && (e.cw !== cam.cw || e.ch !== cam.ch)) {
761
+ cam.cw = e.cw; cam.ch = e.ch;
762
+ cam.w = e.vw != null ? e.vw : cam.w;
763
+ cam.h = e.vh != null ? e.vh : cam.h;
764
+ pendingCamSize = true;
765
+ }
766
+ }
767
+ if (doc && e.target && e.__chk == null) {
768
+ if (pendingCamSize) { flushCamSize(); applyCamScroll(); }
769
+ evaluateCheck(e);
770
+ }
771
+ }
772
+ }
773
+ if (pendingCamSize) { flushCamSize(); applyCamScroll(); }
774
+ }
775
+
776
+ function seek(T, fromScrub) {
777
+ var wasBehind = T < playhead;
778
+ playhead = Math.max(0, Math.min(T, trial().durMs));
779
+ if (!fromScrub) scrub.value = String(playhead);
780
+ if (iframe) {
781
+ // Backward seek: patches are state snapshots but attribute /
782
+ // characterData patches aren't reversible — rebuild from the
783
+ // initial DOM and re-apply forward. Cheap for typical trials.
784
+ if (wasBehind) rebuildFrame(playhead);
785
+ else applyDomUpTo(playhead);
786
+ } else {
787
+ // Trace tier: no reconstruction, but the camera fold still runs so
788
+ // the cursor projection tracks scroll/resize state.
789
+ if (wasBehind) { seedCamera(); appliedIdx = 0; pendingCamSize = true; }
790
+ applyDomUpTo(playhead);
791
+ }
792
+ drawOverlay();
793
+ drawKeycast();
794
+ drawLane();
795
+ drawTicker();
796
+ updateStatusChips();
797
+ clock.textContent = fmtClock(playhead) + ' / ' + fmtClock(trial().durMs);
798
+ scrub.setAttribute('aria-valuetext',
799
+ fmtClock(playhead) + ' of ' + fmtClock(trial().durMs));
800
+ }
801
+
802
+ // ── Overlay drawing ──
803
+ // Everything draws in CLIENT space then letterboxes onto the stage:
804
+ // stagePt = (ox + clientX·k, oy + clientY·k). New recordings carry
805
+ // cx/cy; legacy events re-project page − runningScroll at each point's
806
+ // OWN timestamp (the scan folds scroll state as it walks). Trail
807
+ // segments break at camera discontinuities (resize) and at uncertain
808
+ // interactions — a confident-looking trail must never bridge a state
809
+ // change it can't vouch for.
810
+ function projectEvent(e, scroll) {
811
+ var cx = e.cx != null ? e.cx : (e.x || 0) - scroll.x;
812
+ var cy = e.cy != null ? e.cy : (e.y || 0) - scroll.y;
813
+ return { x: ox + cx * k, y: oy + cy * k };
814
+ }
815
+
816
+ function drawOverlay() {
817
+ ctx.clearRect(0, 0, overlay.width, overlay.height);
818
+ var events = trial().events;
819
+ var seed = trial().camera || {};
820
+ var scroll = { x: seed.x || 0, y: seed.y || 0 };
821
+ var segments = [[]]; // trail polyline segments (break on state change)
822
+ var lastPos = null;
823
+ var lastPosUncertain = false;
824
+ var ripples = [];
825
+ for (var i = 0; i < events.length; i++) {
826
+ var e = events[i];
827
+ if (e.t > playhead) break;
828
+ if (e.kind === 'scroll' && e.el == null && !e.redacted) {
829
+ scroll.x = e.x || 0; scroll.y = e.y || 0;
830
+ if (e.cx == null) segments.push([]); // legacy trail: camera jump
831
+ } else if (DISCRETE_KINDS[e.kind] && e.sx != null &&
832
+ (e.sx !== scroll.x || e.sy !== scroll.y)) {
833
+ // Interaction camera snapshots update the running scroll for the
834
+ // legacy projection path too (async-notification gap).
835
+ scroll.x = e.sx; scroll.y = e.sy;
836
+ } else if (e.kind === 'resize') {
837
+ segments.push([]); // camera discontinuity
838
+ } else if (e.kind === 'mousemove' || e.kind === 'touchmove') {
839
+ var p = projectEvent(e, scroll);
840
+ p.t = e.t;
841
+ if (e.t >= playhead - 2500) segments[segments.length - 1].push(p);
842
+ lastPos = p;
843
+ } else if (e.kind === 'click' || e.kind === 'mousedown' || e.kind === 'touchstart') {
844
+ var q = projectEvent(e, scroll);
845
+ q.t = e.t;
846
+ lastPos = q;
847
+ lastPosUncertain = !!(e.__chk && e.__chk.status !== 'ok');
848
+ if (lastPosUncertain) segments.push([]); // sever the trail
849
+ if (e.t >= playhead - 600) ripples.push(q);
850
+ }
851
+ }
852
+ // Cursor trail: fading polyline per segment
853
+ segments.forEach(function (seg) {
854
+ for (var s = 1; s < seg.length; s++) {
855
+ var age = (playhead - seg[s].t) / 2500;
856
+ ctx.strokeStyle = 'rgba(211,47,47,' + (0.85 * (1 - age)).toFixed(3) + ')';
857
+ ctx.lineWidth = 2;
858
+ ctx.beginPath();
859
+ ctx.moveTo(seg[s - 1].x, seg[s - 1].y);
860
+ ctx.lineTo(seg[s].x, seg[s].y);
861
+ ctx.stroke();
862
+ }
863
+ });
864
+ // Click ripples: expanding rings over 600ms
865
+ ripples.forEach(function (r) {
866
+ var frac = (playhead - r.t) / 600;
867
+ ctx.strokeStyle = 'rgba(25,118,210,' + (1 - frac).toFixed(3) + ')';
868
+ ctx.lineWidth = 2;
869
+ ctx.beginPath();
870
+ ctx.arc(r.x, r.y, 4 + frac * 22, 0, Math.PI * 2);
871
+ ctx.stroke();
872
+ });
873
+ // Cursor: confident filled dot, or the uncertain glyph (dashed amber
874
+ // ring + hollow center) when the latest interaction failed its check.
875
+ if (lastPos) {
876
+ if (lastPosUncertain) {
877
+ ctx.strokeStyle = UNCERTAIN_COLOR;
878
+ ctx.lineWidth = 2;
879
+ ctx.setLineDash([3, 3]);
880
+ ctx.beginPath();
881
+ ctx.arc(lastPos.x, lastPos.y, 7, 0, Math.PI * 2);
882
+ ctx.stroke();
883
+ ctx.setLineDash([]);
884
+ ctx.fillStyle = UNCERTAIN_COLOR;
885
+ ctx.beginPath();
886
+ ctx.arc(lastPos.x, lastPos.y, 2, 0, Math.PI * 2);
887
+ ctx.fill();
888
+ } else {
889
+ ctx.fillStyle = '#d32f2f';
890
+ ctx.beginPath();
891
+ ctx.arc(lastPos.x, lastPos.y, 5, 0, Math.PI * 2);
892
+ ctx.fill();
893
+ ctx.strokeStyle = '#fff';
894
+ ctx.lineWidth = 1.5;
895
+ ctx.stroke();
896
+ }
897
+ }
898
+ }
899
+
900
+ // ── Keycast (bottom-of-stage chips) ──
901
+ // Pairs keydown with its keyup to get each key's [down, up] window, then
902
+ // returns the ones "live" at T: down and not yet released, or released
903
+ // within KEYCAST_FADE_MS. Non-redacted pairs match by e.code (handles
904
+ // overlapping holds, e.g. Shift+A); redacted pairs carry no code (the
905
+ // identity itself is withheld), so they match FIFO against each other —
906
+ // an approximation that assumes redacted keystrokes don't overlap, true
907
+ // for the single-field-typing case this exists to cover.
908
+ function computeKeycastChips(T) {
909
+ var events = trial().events;
910
+ var openByCode = new Map();
911
+ var redactedQueue = [];
912
+ var tokens = [];
913
+ for (var i = 0; i < events.length; i++) {
914
+ var e = events[i];
915
+ if (e.t > T) break;
916
+ if (e.kind !== 'keydown' && e.kind !== 'keyup') continue;
917
+ if (e.redacted) {
918
+ if (e.kind === 'keydown') {
919
+ var rtok = { redacted: true, downT: e.t, upT: null };
920
+ redactedQueue.push(rtok);
921
+ tokens.push(rtok);
922
+ } else {
923
+ var rt = redactedQueue.shift();
924
+ if (rt) rt.upT = e.t;
925
+ }
926
+ continue;
927
+ }
928
+ if (e.kind === 'keydown') {
929
+ // Auto-repeat sends keydown without an intervening keyup; keep the
930
+ // original token open rather than starting a new one.
931
+ if (!openByCode.has(e.code)) {
932
+ var tok = { key: e.key, downT: e.t, upT: null };
933
+ openByCode.set(e.code, tok);
934
+ tokens.push(tok);
935
+ }
936
+ } else {
937
+ var open = openByCode.get(e.code);
938
+ if (open) { open.upT = e.t; openByCode.delete(e.code); }
939
+ }
940
+ }
941
+ return tokens.filter(function (tok) {
942
+ return tok.upT == null || T <= tok.upT + KEYCAST_FADE_MS;
943
+ });
944
+ }
945
+
946
+ function drawKeycast() {
947
+ var chips = computeKeycastChips(playhead);
948
+ keycast.textContent = '';
949
+ chips.forEach(function (tok) {
950
+ var chip = el('span', 'replay-key-chip' + (tok.redacted ? ' replay-key-chip--redacted' : ''),
951
+ tok.redacted ? '•' : formatKeyLabel(tok.key));
952
+ if (tok.upT != null && playhead > tok.upT) {
953
+ var frac = Math.min(1, (playhead - tok.upT) / KEYCAST_FADE_MS);
954
+ chip.style.opacity = String(1 - frac);
955
+ }
956
+ keycast.appendChild(chip);
957
+ });
958
+ }
959
+
960
+ // ── Marker lane (per trial; redrawn per seek so check marks appear) ──
961
+ function drawLane() {
962
+ var lctx = lane.getContext('2d');
963
+ lctx.clearRect(0, 0, lane.width, lane.height);
964
+ lctx.fillStyle = '#f1efe9';
965
+ lctx.fillRect(0, 0, lane.width, lane.height);
966
+ var dur = Math.max(trial().durMs, 1);
967
+ var events = trial().events;
968
+ // Away-bands first (blur→focus, hidden→visible), then point markers.
969
+ var awayStart = null;
970
+ for (var i = 0; i < events.length; i++) {
971
+ var e = events[i];
972
+ if (e.kind === 'blur' || (e.kind === 'visibility' && e.hidden)) {
973
+ if (awayStart === null) awayStart = e.t;
974
+ } else if (e.kind === 'focus' || (e.kind === 'visibility' && !e.hidden)) {
975
+ if (awayStart !== null) {
976
+ lctx.fillStyle = 'rgba(245,124,0,0.45)';
977
+ lctx.fillRect(awayStart / dur * lane.width, 0,
978
+ Math.max(2, (e.t - awayStart) / dur * lane.width), lane.height);
979
+ awayStart = null;
980
+ }
981
+ }
982
+ }
983
+ if (awayStart !== null) { // still away at trial end
984
+ lctx.fillStyle = 'rgba(245,124,0,0.45)';
985
+ lctx.fillRect(awayStart / dur * lane.width, 0,
986
+ lane.width - awayStart / dur * lane.width, lane.height);
987
+ }
988
+ events.forEach(function (e) {
989
+ var color = MARKER_COLORS[e.kind];
990
+ // Alignment-check marks: uncertain interactions paint amber so the
991
+ // analyst can SCRUB TO the exact failing moment.
992
+ if (e.__chk && e.__chk.status !== 'ok') color = UNCERTAIN_COLOR;
993
+ if (!color) return;
994
+ lctx.fillStyle = color;
995
+ lctx.fillRect(e.t / dur * lane.width - 1, 2, 2, lane.height - 4);
996
+ });
997
+ }
998
+
999
+ function checkSummary() {
1000
+ var events = trial().events;
1001
+ var ok = 0, uncertain = 0, redacted = 0;
1002
+ for (var i = 0; i < events.length; i++) {
1003
+ var c = events[i].__chk;
1004
+ if (!c) continue;
1005
+ if (c.status === 'ok') ok++;
1006
+ else if (c.status === 'redacted') redacted++;
1007
+ else uncertain++;
1008
+ }
1009
+ return { ok: ok, uncertain: uncertain, redacted: redacted };
1010
+ }
1011
+
1012
+ function updateStatusChips() {
1013
+ var s = checkSummary();
1014
+ if (model.tier !== 'dom') {
1015
+ alignChip.textContent = '';
1016
+ } else if (model.legacy) {
1017
+ alignChip.textContent = ''; // the legacy chip already says it all
1018
+ } else if (s.uncertain > 0) {
1019
+ alignChip.textContent = '⚠ ' + s.uncertain + ' interaction(s) failed the alignment self-check';
1020
+ alignChip.className = 'replay-note replay-warn';
1021
+ } else if (s.redacted > 0 && s.ok === 0) {
1022
+ alignChip.textContent = 'alignment unverified (redacted interactions)';
1023
+ alignChip.className = 'replay-note';
1024
+ } else if (s.ok > 0) {
1025
+ alignChip.textContent = 'alignment verified (' + s.ok + ' check' + (s.ok === 1 ? '' : 's') +
1026
+ (s.redacted > 0 ? ', ' + s.redacted + ' redacted' : '') + ')';
1027
+ alignChip.className = 'replay-note';
1028
+ } else {
1029
+ alignChip.textContent = '';
1030
+ }
1031
+ var fails = counters.patchFailures + counters.scrollFailures;
1032
+ if (fails > 0) {
1033
+ failChip.textContent = '⚠ ' + fails + ' recorded change(s) could not be reapplied. Replay may diverge from the original session.';
1034
+ failChip.style.display = '';
1035
+ } else {
1036
+ failChip.style.display = 'none';
1037
+ }
1038
+ }
1039
+
1040
+ function drawTicker() {
1041
+ var events = trial().events;
1042
+ var recent = [];
1043
+ for (var i = events.length - 1; i >= 0 && recent.length < 3; i--) {
1044
+ var e = events[i];
1045
+ if (e.t > playhead) continue;
1046
+ if (e.kind === 'mousemove' || e.kind === 'touchmove') continue;
1047
+ var label = e.kind;
1048
+ if (e.kind === 'paste' || e.kind === 'ch:drop') label += ' (' + (e.len == null ? '?' : e.len) + ' ch)';
1049
+ if (e.kind === 'keydown' && e.key) label += ' ' + JSON.stringify(e.key);
1050
+ if (e.kind === 'input') label += e.redacted ? ' [redacted, ' + e.value_len + ' ch]' : (' "' + String(e.value || '').slice(0, 24) + '"');
1051
+ if (e.__chk && e.__chk.status === 'uncertain') label += ' ⚠(' + e.__chk.reasons[0] + ')';
1052
+ recent.unshift('[' + fmtClock(e.t) + '] ' + label);
1053
+ }
1054
+ ticker.textContent = recent.join(' · ') || '—';
1055
+ }
1056
+
1057
+ // ── Playback loop ──
1058
+ var lastFrame = null;
1059
+ function tick(ts) {
1060
+ if (!playing) return;
1061
+ if (lastFrame != null) {
1062
+ seek(playhead + (ts - lastFrame) * speed);
1063
+ }
1064
+ lastFrame = ts;
1065
+ if (playhead >= trial().durMs) {
1066
+ if (autoAdvance && trialIdx < trials.length - 1) {
1067
+ loadTrial(trialIdx + 1); // continue playing into the next trial
1068
+ requestAnimationFrame(tick);
1069
+ return;
1070
+ }
1071
+ playing = false;
1072
+ playBtn.textContent = '▶';
1073
+ playBtn.setAttribute('aria-label', 'Play');
1074
+ return;
1075
+ }
1076
+ requestAnimationFrame(tick);
1077
+ }
1078
+
1079
+ playBtn.addEventListener('click', function () {
1080
+ if (playing) {
1081
+ playing = false;
1082
+ playBtn.textContent = '▶';
1083
+ playBtn.setAttribute('aria-label', 'Play');
1084
+ return;
1085
+ }
1086
+ if (playhead >= trial().durMs) seek(0);
1087
+ playing = true;
1088
+ playBtn.textContent = '❚❚';
1089
+ playBtn.setAttribute('aria-label', 'Pause');
1090
+ lastFrame = null;
1091
+ requestAnimationFrame(tick);
1092
+ });
1093
+ speedSel.addEventListener('change', function () { speed = Number(speedSel.value) || 1; });
1094
+ scrub.addEventListener('input', function () { seek(Number(scrub.value), true); });
1095
+ scrub.addEventListener('keydown', function (ev) {
1096
+ var step = trial().durMs * 0.05;
1097
+ if (ev.key === 'PageUp') { seek(playhead + step); ev.preventDefault(); }
1098
+ else if (ev.key === 'PageDown') { seek(playhead - step); ev.preventDefault(); }
1099
+ else if (ev.key === 'Home') { seek(0); ev.preventDefault(); }
1100
+ else if (ev.key === 'End') { seek(trial().durMs); ev.preventDefault(); }
1101
+ });
1102
+ function updateSessionPos() {
1103
+ sessionPos.textContent = 'Trial ' + (trialIdx + 1) + ' of ' + trials.length;
1104
+ }
1105
+
1106
+ // Loads trial i's DOM/camera/scrub state WITHOUT touching playing/playBtn
1107
+ // — the continuous-playback boundary in tick() below calls this directly
1108
+ // so play keeps running across the cut; selectTrial() (manual trial-pick
1109
+ // and boot) wraps it with the playing=false reset a user-driven jump
1110
+ // should have.
1111
+ function loadTrial(i) {
1112
+ trialIdx = i;
1113
+ trialSel.value = String(i);
1114
+ scrub.max = String(trial().durMs);
1115
+ trialHadIframe = false;
1116
+ trialHadShadow = false;
1117
+ seedCamera();
1118
+ sizeStage();
1119
+ // DPR advisory: resolution-conditional CSS cannot be reproduced in an
1120
+ // iframe that inherits the analyst's DPR (declared limitation, loud).
1121
+ var recDpr = (trial().camera && trial().camera.dpr) ||
1122
+ (model.viewport && model.viewport.dpr) || null;
1123
+ if (recDpr && window.devicePixelRatio && recDpr !== window.devicePixelRatio) {
1124
+ dprChip.textContent = 'Recorded at DPR ' + recDpr + ', now viewing at DPR ' +
1125
+ window.devicePixelRatio + '. Resolution-dependent styling may differ.';
1126
+ dprChip.style.display = '';
1127
+ } else {
1128
+ dprChip.style.display = 'none';
1129
+ }
1130
+ if (iframe) {
1131
+ rebuildFrame(0);
1132
+ } else {
1133
+ appliedIdx = 0;
1134
+ pendingCamSize = true;
1135
+ flushCamSize(); // trace tier: transform initializes from the seed
1136
+ }
1137
+ seek(0);
1138
+ updateSessionPos();
1139
+ }
1140
+ function selectTrial(i) {
1141
+ playing = false;
1142
+ playBtn.textContent = '▶';
1143
+ playBtn.setAttribute('aria-label', 'Play');
1144
+ loadTrial(i);
1145
+ }
1146
+ trialSel.addEventListener('change', function () {
1147
+ selectTrial(Number(trialSel.value) || 0);
1148
+ });
1149
+
1150
+ // Analyst-side resizes (report sidebar, browser zoom, window resize)
1151
+ // re-derive the stage box and transform — the reconstruction must track
1152
+ // its container, not just the recording (Sol round-1 finding 11).
1153
+ if (typeof ResizeObserver !== 'undefined') {
1154
+ var ro = new ResizeObserver(function () {
1155
+ var newW = Math.min(mount.clientWidth || 720, 960);
1156
+ if (newW === stageW) return;
1157
+ sizeStage();
1158
+ pendingCamSize = true;
1159
+ flushCamSize();
1160
+ drawOverlay();
1161
+ drawLane();
1162
+ });
1163
+ ro.observe(mount);
1164
+ }
1165
+
1166
+ // Test/debug surface (used by the alignment battery; not a public API).
1167
+ mount._chReplayDebug = {
1168
+ seek: function (t) { seek(t); },
1169
+ selectTrial: selectTrial,
1170
+ getChecks: function () {
1171
+ return trial().events
1172
+ .filter(function (e) { return e.__chk; })
1173
+ .map(function (e) {
1174
+ return { t: e.t, kind: e.kind, status: e.__chk.status, reasons: e.__chk.reasons };
1175
+ });
1176
+ },
1177
+ getCamera: function () { return { x: cam.x, y: cam.y, cw: cam.cw, ch: cam.ch, k: k, ox: ox, oy: oy }; },
1178
+ getCounters: function () { return { patchFailures: counters.patchFailures, scrollFailures: counters.scrollFailures }; },
1179
+ frameReady: function () { return iframe ? domReady : true; }
1180
+ };
1181
+
1182
+ // ── Boot ──
1183
+ selectTrial(0);
1184
+ };
1185
+ })();