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