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.
- package/CHANGELOG.md +156 -0
- package/CITATION.cff +29 -0
- package/LICENSE +21 -0
- package/README.md +80 -21
- package/bin/cyborg-hunter.js +6 -2
- package/dist/cyborg-hunter-replay.js +3 -0
- package/dist/cyborg-hunter.esm.js +114 -22
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/dist/extension-guard-friction.js +5 -5
- package/dist/extension-guard-honeypot.js +1 -1
- package/package.json +15 -3
- package/src/cli/analyzers/edge-exit.js +4 -1
- package/src/cli/analyzers/phase-scope.js +83 -0
- package/src/cli/analyzers/summary.js +161 -28
- package/src/cli/analyzers/triage.js +59 -27
- package/src/cli/config.js +26 -1
- package/src/cli/extract-core.js +552 -0
- package/src/cli/ingest.js +250 -193
- package/src/cli/init.js +1 -1
- package/src/cli/preview-entry.js +36 -0
- package/src/cli/renderers/event-log.js +18 -19
- package/src/cli/renderers/extensions.js +12 -3
- package/src/cli/renderers/html-index-core.js +1273 -0
- package/src/cli/renderers/html-index.js +13 -1047
- package/src/cli/renderers/replay-assets.js +67 -0
- package/src/cli/renderers/replay-viewer.client.js +1185 -0
- package/src/cli/renderers/session-timeline-core.js +907 -0
- package/src/cli/renderers/session-timeline.js +33 -0
- package/src/cli/renderers/summary-csv.js +5 -0
- package/src/cli/renderers/trajectories-core.js +717 -0
- package/src/cli/renderers/trajectories.js +31 -635
- package/src/cli/renderers/triage-md.js +10 -4
- package/src/cli/renderers/typing-profile-core.js +211 -0
- package/src/cli/renderers/typing-profile.js +16 -186
- package/src/cli/report.js +42 -8
- package/src/core/monitor.js +77 -7
- package/src/core/scoring.js +11 -2
- package/src/core/signals/browser.js +51 -18
- package/src/core/signals/clipboard.js +10 -2
- package/src/core/signals/dom-protection.js +9 -0
- package/src/core/signals/focus.js +16 -2
- package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
- package/src/jspsych/extension-cyborg-hunter.js +9 -2
- package/src/jspsych/extension-guard-friction.js +43 -14
- package/src/jspsych/extension-guard-honeypot.js +25 -1
- package/src/replay/capture-dom.js +575 -0
- package/src/replay/capture-trace.js +468 -0
- package/src/replay/index.js +104 -0
- package/src/replay/persistence.js +141 -0
- package/src/replay/recorder.js +315 -0
- package/src/replay/serializer.js +119 -0
- package/src/replay/viewer-model.js +125 -0
- package/src/shared/constants.js +12 -6
- package/src/shared/paths.js +20 -0
- package/src/shared/schema.js +5 -0
- package/src/shared/validation.js +55 -0
- 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, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
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
|
+
})();
|