cyborg-hunter 0.7.5 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/CITATION.cff +2 -2
- package/README.md +22 -7
- package/dist/cyborg-hunter-replay.js +3 -3
- package/dist/cyborg-hunter.esm.js +5 -2
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/package.json +10 -2
- package/src/cli/analyzers/score-weights.js +145 -0
- package/src/cli/analyzers/triage.js +55 -39
- package/src/cli/config.js +15 -0
- package/src/cli/ingest.js +311 -57
- package/src/cli/renderers/html-index-core.js +88 -22
- package/src/cli/renderers/html-index.js +7 -5
- package/src/cli/renderers/replay-assets.js +61 -7
- package/src/cli/renderers/replay-client-source.js +102 -0
- package/src/cli/renderers/replay-viewer.client.js +1750 -595
- package/src/cli/renderers/score-weights.js +16 -0
- package/src/cli/renderers/summary-csv.js +2 -0
- package/src/cli/renderers/trajectories-core.js +2 -1
- package/src/cli/renderers/triage-md.js +9 -2
- package/src/cli/report.js +16 -3
- package/src/core/monitor.js +12 -13
- package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/replay/capture-dom.js +470 -458
- package/src/replay/capture-trace.js +688 -271
- package/src/replay/delivery.js +82 -0
- package/src/replay/dom-instantiate.js +779 -0
- package/src/replay/index.js +88 -5
- package/src/replay/initial-state.js +295 -0
- package/src/replay/mutations.js +668 -0
- package/src/replay/node-registry.js +116 -0
- package/src/replay/persistence.js +19 -6
- package/src/replay/recorder.js +342 -73
- package/src/replay/redaction.js +165 -0
- package/src/replay/serializer.js +148 -43
- package/src/replay/snapshot.js +409 -0
- package/src/replay/span.js +55 -0
- package/src/replay/viewer-model.js +293 -102
- package/src/shared/constants.js +1 -1
- package/src/shared/inline-safe.js +80 -0
- package/src/shared/schema-v2-validator.js +595 -0
- package/src/shared/schema.js +1 -0
- package/src/shared/validation.js +1 -1
- package/tools/convert/jspsych-v1-to-v2.mjs +432 -0
|
@@ -1,43 +1,85 @@
|
|
|
1
1
|
// src/cli/renderers/replay-viewer.client.js
|
|
2
|
-
// Browser-side replay viewer
|
|
3
|
-
//
|
|
2
|
+
// Browser-side replay viewer for SessionRecording v2 (spec r2), embedded
|
|
3
|
+
// verbatim into the HTML report.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// IT IS NOT INLINED ALONE. `replay-client-source.js` concatenates
|
|
6
|
+
// `src/replay/dom-instantiate.js` ahead of it inside one strict IIFE, so
|
|
7
|
+
// `mountTree`, `applyPatch` and `applyPatches` — the single reading of spec §4
|
|
8
|
+
// this repo holds — are in scope here. Reading this file on its own will show
|
|
9
|
+
// three undefined functions; that is the concatenation contract, not a bug
|
|
10
|
+
// (T5 Task 2's recorded decision, machine-checked in
|
|
11
|
+
// tests/replay/dom-instantiate.test.js).
|
|
8
12
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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).
|
|
13
|
+
// Contract: window.initChReplayViewer(mountEl, model), where model is
|
|
14
|
+
// `buildViewerModel`'s v2 output (design §9): segments with segment-relative
|
|
15
|
+
// event times and a folded camera seed, session-level `viewportChanges` /
|
|
16
|
+
// `stylesheetEvents` in ABSOLUTE wire time, and no CH-specific panel data
|
|
17
|
+
// unless the file's own field is present.
|
|
18
18
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
19
|
+
// ── What v2 changed here, and why each thing went ──
|
|
20
|
+
//
|
|
21
|
+
// THE RECONSTRUCTION IS INSTANTIATED, NEVER PARSED. v1 interpolated a captured
|
|
22
|
+
// HTML string into `srcdoc` and applied child-list patches with `innerHTML`.
|
|
23
|
+
// Both are gone: a keyframe is a DomNode tree mounted node by node through
|
|
24
|
+
// `mountTree`, and the four `dom.*` patches address integer ids through the
|
|
25
|
+
// span's id map. `</script>` breakouts, attribute-escaping bugs and
|
|
26
|
+
// parser-normalisation drift stop being defended against and start being
|
|
27
|
+
// impossible — which is why the whole nonce-marker apparatus (the harvested
|
|
28
|
+
// attribute map, the child-index path fallback and the reference resolver that
|
|
29
|
+
// tried both) left with it.
|
|
30
|
+
//
|
|
31
|
+
// THE SHELL IS WRITTEN ONCE. `srcdoc` carries the CSP, the no-referrer meta,
|
|
32
|
+
// the viewer's own shell rules and nothing else; everything after boot is
|
|
33
|
+
// synchronous DOM work through `contentDocument`. v1 rebuilt the whole frame
|
|
34
|
+
// through `srcdoc` + `onload` on every backward seek, which is why it needed a
|
|
35
|
+
// generation counter to drop stale loads and why a seek could not be read back
|
|
36
|
+
// synchronously. A restore is now `mountTree` plus a walk. The one
|
|
37
|
+
// remaining async path is the analyst's "Load external CSS" button, which
|
|
38
|
+
// changes the CSP string itself and therefore genuinely needs a new document.
|
|
39
|
+
//
|
|
40
|
+
// KEYFRAME SPANS REPLACE SELF-CONTAINED TRIALS. A segment with `initialDom` is
|
|
41
|
+
// a keyframe; the ones after it are continuations that carry on from its end
|
|
42
|
+
// state. Restoring segment i means mounting `segments[spanStart].initialDom`,
|
|
43
|
+
// deriving stylesheet state, seeding `initial_state`, and replaying every
|
|
44
|
+
// event from the keyframe origin to the playhead — merged with the session
|
|
45
|
+
// streams at spec §7's tie precedence.
|
|
46
|
+
//
|
|
47
|
+
// Coordinate model: unchanged in spirit, simpler in fact. The stage shows the
|
|
48
|
+
// RECORD-TIME VIEWPORT; the iframe is sized to the record-time layout box and
|
|
49
|
+
// letterboxed into a fixed stage. v2's coordinates are normative CLIENT
|
|
50
|
+
// coordinates, so v1's page-coordinate re-projection — and the reduced-
|
|
51
|
+
// guarantees banner that flagged it — is deleted rather than ported.
|
|
23
52
|
//
|
|
24
53
|
// Anatomy per mount:
|
|
25
|
-
// header tier badge ·
|
|
26
|
-
// stage tier 'dom': sandboxed <iframe
|
|
54
|
+
// header tier badge · segment <select> · play/pause · speed · clock · chips
|
|
55
|
+
// stage tier 'dom': ONE sandboxed <iframe>, mounted and patched in place
|
|
27
56
|
// tier 'trace': neutral gray stage with a label
|
|
28
57
|
// + <canvas> overlay (cursor trail, click ripples)
|
|
29
|
-
// lane per-
|
|
30
|
-
// scrub <input type=range> in ms,
|
|
58
|
+
// lane per-segment marker lane (clipboard/mutations/bands/check marks)
|
|
59
|
+
// scrub <input type=range> in ms, rAF-coalesced into the span walk
|
|
31
60
|
// ticker text line of the events nearest the playhead
|
|
32
61
|
|
|
33
62
|
(function () {
|
|
34
63
|
'use strict';
|
|
35
64
|
|
|
65
|
+
// Lane colours, keyed by spec §5's dotted types. `dom.*` share one colour:
|
|
66
|
+
// the lane answers "was the page changing here", not which verb ran.
|
|
36
67
|
var MARKER_COLORS = {
|
|
37
|
-
paste: '#d32f2f', '
|
|
38
|
-
'
|
|
39
|
-
|
|
68
|
+
'clipboard.paste': '#d32f2f', 'clipboard.drop': '#7b1fa2',
|
|
69
|
+
'clipboard.copy': '#f57c00', 'clipboard.cut': '#f57c00',
|
|
70
|
+
'recording.capture_stopped': '#616161',
|
|
71
|
+
'dom.add': '#1976d2', 'dom.remove': '#1976d2',
|
|
72
|
+
'dom.attr': '#1976d2', 'dom.text': '#1976d2',
|
|
73
|
+
'input.value': '#388e3c', 'input.checked': '#388e3c', 'input.select': '#388e3c',
|
|
74
|
+
// Adopted vocabulary (design §7). Media and fullscreen are STATE changes
|
|
75
|
+
// the viewer reports rather than reproduces, so the lane is where they
|
|
76
|
+
// land: a badge says what the state is now, a marker says when it changed.
|
|
77
|
+
'fullscreen.enter': '#00838f', 'fullscreen.exit': '#00838f',
|
|
78
|
+
'canvas.snapshot': '#5e35b1',
|
|
79
|
+
'media.play': '#00897b', 'media.pause': '#00897b', 'media.ended': '#00897b',
|
|
80
|
+
'media.seeked': '#00897b', 'media.time': '#00897b'
|
|
40
81
|
};
|
|
82
|
+
var GUARD_COLOR = '#d32f2f';
|
|
41
83
|
var UNCERTAIN_COLOR = '#b26a00';
|
|
42
84
|
|
|
43
85
|
// Self-check tolerances (CSS px, unscaled): camera ±1; rect edges ±3
|
|
@@ -48,21 +90,97 @@
|
|
|
48
90
|
var TOL_CONTAIN = 2;
|
|
49
91
|
var TOL_STAGE = 2;
|
|
50
92
|
|
|
51
|
-
//
|
|
93
|
+
// The §8 comparisons, by name, for the `skipped` array every check carries:
|
|
94
|
+
// which of them could not RUN on this event, because the recording did not
|
|
95
|
+
// state the data or the reconstruction could not be measured. SIX names for
|
|
96
|
+
// five predicates — check 1 is two comparisons over independent fields.
|
|
97
|
+
//
|
|
98
|
+
// A skip is NOT a failure and never reaches the chip, the lane or the glyph.
|
|
99
|
+
// It exists so that `status === 'ok' && skipped.length === 0` is the only
|
|
100
|
+
// combination meaning every comparison was made and every one held: without
|
|
101
|
+
// it, an event whose rect the recording never carried counts in the same
|
|
102
|
+
// "alignment verified (N checks)" numerator as one that was verified in full.
|
|
103
|
+
var ALL_PREDICATES = ['camera.scroll', 'camera.client_w', 'rect', 'containment',
|
|
104
|
+
'hit-test', 'stage'];
|
|
105
|
+
|
|
106
|
+
// Keycast: how long a chip keeps fading after its key.up, in segment-relative
|
|
52
107
|
// ms. Purely a function of playhead (like the click-ripple fade in
|
|
53
108
|
// drawOverlay below), not real time, so seeking lands on the same visual
|
|
54
109
|
// state playing to it would.
|
|
55
110
|
var KEYCAST_FADE_MS = 500;
|
|
56
111
|
|
|
57
|
-
// Buffer-cap explanation
|
|
58
|
-
//
|
|
59
|
-
//
|
|
112
|
+
// Buffer-cap explanation FALLBACK: recorder.js's REPLAY_DEFAULTS, quoted only
|
|
113
|
+
// when the recording states no limit of its own.
|
|
114
|
+
//
|
|
115
|
+
// It usually does state one. The configured cap that was actually crossed is
|
|
116
|
+
// CH's own diagnostic rather than a standard field, so it rides in the §5.7
|
|
117
|
+
// event's vendor namespace — `recording.capture_stopped.extensions
|
|
118
|
+
// ['cyborg-hunter'] = {limit_events}` or `{limit_chars}` (recorder.js), which
|
|
119
|
+
// `serializer.js` and `buildViewerModel` both copy through whole. So the
|
|
120
|
+
// §5.7 re-point reads the RECORDING's numbers out of the event stream this
|
|
121
|
+
// viewer already walks; it needs no new model field, and the defaults below
|
|
122
|
+
// survive only for a file that carries no such event (a foreign producer, or
|
|
123
|
+
// `reason: "error"`). See `findCaptureStop` / `captureStopLimit` below.
|
|
60
124
|
var CAP_DEFAULTS = { events: 50000, chars: 8000000 };
|
|
61
125
|
|
|
62
|
-
|
|
63
|
-
|
|
126
|
+
// Non-move input events: the ones that MAY carry §6 alignment blocks and the
|
|
127
|
+
// ones the overlay draws as discrete marks.
|
|
128
|
+
var DISCRETE_TYPES = {
|
|
129
|
+
'mouse.down': true, 'mouse.up': true, 'mouse.click': true,
|
|
130
|
+
'touch.start': true, 'touch.end': true,
|
|
131
|
+
'key.down': true, 'key.up': true
|
|
64
132
|
};
|
|
65
133
|
|
|
134
|
+
// §7's total event order: at equal `t`, precedence is stylesheet_events →
|
|
135
|
+
// viewport_changes → segment events. The ranks ARE that rule; the merge sorts
|
|
136
|
+
// on (t, rank) and JS sorts are stable, so array order decides within a
|
|
137
|
+
// stream, which is the other half of §7.
|
|
138
|
+
// The rank is ONLY an ordering key: every walk entry also carries the name of
|
|
139
|
+
// the stream it came from, set where it is built. Deriving the name from the
|
|
140
|
+
// rank instead would make a test of the precedence circular — swapping the
|
|
141
|
+
// two constants would swap the labels with them and read as correct.
|
|
142
|
+
var RANK_SHEET = 0;
|
|
143
|
+
var RANK_VIEWPORT = 1;
|
|
144
|
+
var RANK_EVENT = 2;
|
|
145
|
+
|
|
146
|
+
// How the viewer recognises its own shell after writing it. The parser drops
|
|
147
|
+
// attributes on a re-parsed <html>, so the sentinel is a <meta>.
|
|
148
|
+
var SHELL_META = 'ch-replay-shell';
|
|
149
|
+
|
|
150
|
+
// Spec §5's WHOLE vocabulary, not CH's capture subset (design §7): the
|
|
151
|
+
// conformance moment is this viewer playing a foreign conforming file, and
|
|
152
|
+
// `jspsych-full` carries canvas snapshots, fullscreen transitions and
|
|
153
|
+
// content-mode clipboard payloads CH's recorder will never produce.
|
|
154
|
+
//
|
|
155
|
+
// The set exists so §5.8's "skip and count" has a definition of KNOWN that is
|
|
156
|
+
// one list rather than the shape of an if/else chain. A type in here is
|
|
157
|
+
// handled or deliberately left to the overlay/lane/ticker; a type not in here
|
|
158
|
+
// is counted and warned about once.
|
|
159
|
+
var KNOWN_TYPES = {};
|
|
160
|
+
('dom.add dom.remove dom.attr dom.text ' +
|
|
161
|
+
'mouse.move mouse.down mouse.up mouse.click ' +
|
|
162
|
+
'touch.start touch.move touch.end key.down key.up ' +
|
|
163
|
+
'input.value input.checked input.select ' +
|
|
164
|
+
'clipboard.copy clipboard.cut clipboard.paste clipboard.drop ' +
|
|
165
|
+
'canvas.snapshot ' +
|
|
166
|
+
'media.play media.pause media.ended media.seeked media.time ' +
|
|
167
|
+
'focus blur visibility.hidden visibility.visible ' +
|
|
168
|
+
'fullscreen.enter fullscreen.exit ' +
|
|
169
|
+
'scroll.window scroll.element ' +
|
|
170
|
+
'recording.capture_stopped').split(' ').forEach(function (t) { KNOWN_TYPES[t] = true; });
|
|
171
|
+
|
|
172
|
+
// Canvas presentation (design §3.1). MUST match dom-instantiate.js's
|
|
173
|
+
// VIEWER_OWNED_ATTRS entries — the module refuses both names on both verbs,
|
|
174
|
+
// which is what stops a recording from stripping the selector its own
|
|
175
|
+
// composite is presented through.
|
|
176
|
+
var CANVAS_ATTR = 'data-ch-canvas';
|
|
177
|
+
var CANVAS_RULE_ATTR = 'data-ch-canvas-rule';
|
|
178
|
+
// Nothing but our own encoder writes this string, and it goes inside a CSS
|
|
179
|
+
// `url("…")`, so the shape is checked rather than trusted: a realm without
|
|
180
|
+
// canvas support returns '' from toDataURL (happy-dom does), and a stray
|
|
181
|
+
// quote would end the declaration early.
|
|
182
|
+
var DATA_URL_RE = /^data:image\/[a-z+.-]+;base64,[A-Za-z0-9+/=]*$/;
|
|
183
|
+
|
|
66
184
|
function el(tag, cls, text) {
|
|
67
185
|
var node = document.createElement(tag);
|
|
68
186
|
if (cls) node.className = cls;
|
|
@@ -77,7 +195,7 @@
|
|
|
77
195
|
return m + ':' + (rem < 10 ? '0' : '') + rem;
|
|
78
196
|
}
|
|
79
197
|
|
|
80
|
-
// Human label for a keycast chip.
|
|
198
|
+
// Human label for a keycast chip. `key` already names special keys well
|
|
81
199
|
// (Enter, Shift, Backspace, ArrowLeft…); the one gap is the space bar,
|
|
82
200
|
// which arrives as a literal ' ' and would render as an invisible chip.
|
|
83
201
|
function formatKeyLabel(key) {
|
|
@@ -88,14 +206,32 @@
|
|
|
88
206
|
return n.toLocaleString('en-US');
|
|
89
207
|
}
|
|
90
208
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
209
|
+
function num(v) {
|
|
210
|
+
return typeof v === 'number' && isFinite(v) ? v : null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The wire rounds to 0.1 ms (spec §7) and `buildViewerModel` rebases every
|
|
214
|
+
// segment event with `round1(t − origin)`. Session-level streams keep
|
|
215
|
+
// absolute times, so this file rebases them into a segment's frame with the
|
|
216
|
+
// SAME formula — forward, never reverse. That makes an equal-`t` tie between
|
|
217
|
+
// a session stream and a segment event exact by construction, which is what
|
|
218
|
+
// §7's precedence rule needs to mean anything. (Reversing instead —
|
|
219
|
+
// `origin + tRel` — is inexact by up to ~2e-7 ms and would decide ties by
|
|
220
|
+
// float noise; viewer-model.js's conversion comment measures it.)
|
|
221
|
+
function round1(v) {
|
|
222
|
+
return Math.round(v * 10) / 10;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── The reconstruction shell (tier 'dom') ──
|
|
226
|
+
// The recorded DOM is UNTRUSTED (an adversarial participant or extension may
|
|
227
|
+
// have injected markup, and a foreign v2 file is not bound by CH's capture
|
|
228
|
+
// rules at all). sandbox="allow-same-origin" without allow-scripts already
|
|
229
|
+
// blocks scripts; the CSP meta additionally kills frames, form posts,
|
|
230
|
+
// fetch/XHR-carrying elements, and plugin content, while still allowing
|
|
231
|
+
// images/media/styles and fonts — the carriers of visual fidelity. Residual
|
|
232
|
+
// (documented): a participant-injected <img src> can still fire a GET to its
|
|
233
|
+
// host when the analyst loads the replay. no-referrer strips the analyst
|
|
234
|
+
// context.
|
|
99
235
|
function srcdocCsp(allowExternalCss) {
|
|
100
236
|
return "default-src 'none'; img-src * data: blob:; media-src * data: blob:; " +
|
|
101
237
|
(allowExternalCss ? "style-src 'unsafe-inline' https: http:; " : "style-src 'unsafe-inline'; ") +
|
|
@@ -107,105 +243,144 @@
|
|
|
107
243
|
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
108
244
|
}
|
|
109
245
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
246
|
+
// The viewer's OWN rules, kept in one <style> that is always the last child
|
|
247
|
+
// of the shell head so the recording's stylesheets cannot override them.
|
|
248
|
+
//
|
|
249
|
+
// NOTE deliberately ABSENT: the old `html,body{margin:0}` override. It came
|
|
250
|
+
// AFTER the captured CSS and silently overrode the page's own body margins
|
|
251
|
+
// (including `margin:auto` centering) — the single largest cursor-
|
|
252
|
+
// misalignment source on the acceptance fixture (~390px). The reconstruction
|
|
253
|
+
// must lay out exactly as the recorded page did, UA defaults included.
|
|
254
|
+
//
|
|
255
|
+
// The frame is sized to the RECORD-TIME layout width (clientWidth =
|
|
256
|
+
// innerWidth minus any classic scrollbar), so the frame's own root scrollbar
|
|
257
|
+
// must not subtract a second gutter on classic-scrollbar platforms. Hiding
|
|
258
|
+
// the ROOT scroller's bar (scrolling still works programmatically) keeps
|
|
259
|
+
// layout width == camera width everywhere; inner containers keep their bars.
|
|
260
|
+
//
|
|
261
|
+
// The placeholder rule is spec §12's player duty made visible: an element the
|
|
262
|
+
// format cannot carry the content of (an iframe today) reads as "something
|
|
263
|
+
// was here" rather than as blank page. It is an OUTLINE, not a border,
|
|
264
|
+
// deliberately — a border participates in layout, and the §8 rect check
|
|
265
|
+
// compares the replayed box against the capture-time one, so a viewer that
|
|
266
|
+
// grew the box would report a misalignment it caused itself.
|
|
267
|
+
//
|
|
268
|
+
// `html{height:100%}` completes the percentage-height chain. A recorded
|
|
269
|
+
// <body> with `height: 100%` (jsPsych's display element; any page that
|
|
270
|
+
// centres in the viewport) resolves against <html>, which sits OUTSIDE the
|
|
271
|
+
// observed root and is never captured — so whatever gave it its height on
|
|
272
|
+
// the live page (a stylesheet rule, an inline style) is absent here. With
|
|
273
|
+
// <html> at content height the chain collapses: a centred page renders
|
|
274
|
+
// top-left and every anchor rect misses by the centring offset, which is
|
|
275
|
+
// how the first real jsPsych capture played in this viewer (bench harness,
|
|
276
|
+
// 2026-09-03) while the fork — whose shell states this rule — played it
|
|
277
|
+
// right. The rule reproduces the frame's containing block, not any
|
|
278
|
+
// recorded style: body margin is left to the recording / UA default.
|
|
279
|
+
function shellRules() {
|
|
280
|
+
return 'html{height:100%;scrollbar-width:none}' +
|
|
281
|
+
'html::-webkit-scrollbar{width:0;height:0}' +
|
|
282
|
+
'[data-ch-placeholder]{outline:2px dashed #b26a00;outline-offset:-2px;' +
|
|
283
|
+
'background:repeating-linear-gradient(45deg,rgba(178,106,0,.06),' +
|
|
284
|
+
'rgba(178,106,0,.06) 6px,transparent 6px,transparent 12px)}';
|
|
137
285
|
}
|
|
138
286
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
287
|
+
// Written ONCE per mount. It carries no recorded content at all: the
|
|
288
|
+
// stylesheets are inserted by resetSheets() so that one function owns sheet
|
|
289
|
+
// state, and the body is filled by mountTree().
|
|
290
|
+
function buildShell(allowExternalCss, gen) {
|
|
291
|
+
return '<!DOCTYPE html><html><head><meta charset="utf-8">' +
|
|
292
|
+
'<meta http-equiv="Content-Security-Policy" content="' + srcdocCsp(allowExternalCss) + '">' +
|
|
293
|
+
'<meta name="referrer" content="no-referrer">' +
|
|
294
|
+
'<meta name="' + SHELL_META + '" content="' + gen + '">' +
|
|
295
|
+
'<style data-ch-shell-rules>' + shellRules() + '</style>' +
|
|
296
|
+
'</head><body></body></html>';
|
|
147
297
|
}
|
|
148
298
|
|
|
149
|
-
|
|
299
|
+
// `opts.externalCss` (2026-09-03): the report decides UP FRONT, next to the
|
|
300
|
+
// one "Load replay" button, whether href-only sheets may be linked from
|
|
301
|
+
// their origins. Absent → today's strict frame plus the in-place opt-in.
|
|
302
|
+
window.initChReplayViewer = function (mount, model, opts) {
|
|
150
303
|
if (!mount || !model) return;
|
|
151
304
|
if (mount._chReplayInit) return; // double-click guard
|
|
152
305
|
mount._chReplayInit = true;
|
|
306
|
+
var initialExternalCss = !!(opts && opts.externalCss);
|
|
153
307
|
mount.textContent = '';
|
|
154
308
|
|
|
155
|
-
var
|
|
156
|
-
if (
|
|
157
|
-
mount.appendChild(el('p', 'replay-note', 'Recording contains no
|
|
309
|
+
var segments = model.segments || [];
|
|
310
|
+
if (segments.length === 0) {
|
|
311
|
+
mount.appendChild(el('p', 'replay-note', 'Recording contains no segments.'));
|
|
158
312
|
return;
|
|
159
313
|
}
|
|
160
|
-
// Session
|
|
161
|
-
var
|
|
162
|
-
var
|
|
314
|
+
// Session-level streams: ABSOLUTE wire times, rebased per segment at use.
|
|
315
|
+
var sheetEvents = model.stylesheetEvents || [];
|
|
316
|
+
var viewportChanges = model.viewportChanges || [];
|
|
317
|
+
var scrollbar = model.scrollbar || { w: 0, h: 0 };
|
|
163
318
|
|
|
164
319
|
// ── State ──
|
|
165
|
-
var
|
|
166
|
-
var playhead = 0; //
|
|
320
|
+
var segIdx = 0;
|
|
321
|
+
var playhead = 0; // segment-relative ms
|
|
167
322
|
var playing = false;
|
|
168
323
|
var speed = 1;
|
|
169
|
-
// Continuous whole-session playback, default ON: at a
|
|
170
|
-
// playing, load the next
|
|
324
|
+
// Continuous whole-session playback, default ON: at a segment's end while
|
|
325
|
+
// playing, load the next segment and keep going, so an analyst watches a
|
|
171
326
|
// full session as one video instead of stopping at every boundary. The
|
|
172
|
-
// 'pause at
|
|
173
|
-
// original per-trial-stop behavior.
|
|
327
|
+
// 'pause at segment boundaries' toggle flips this off.
|
|
174
328
|
var autoAdvance = true;
|
|
175
|
-
|
|
176
|
-
var
|
|
177
|
-
var
|
|
178
|
-
var
|
|
179
|
-
var
|
|
329
|
+
|
|
330
|
+
var shellReady = false;
|
|
331
|
+
var shellGen = 0; // stamped into each shell; see shellPresent()
|
|
332
|
+
var span = null; // mountTree's live state for the mounted span
|
|
333
|
+
var spanStart = -1; // index of the keyframe the mounted span opens at
|
|
334
|
+
var spanEnd = -1;
|
|
335
|
+
var walk = []; // merged span entries, in application order
|
|
336
|
+
var appliedIdx = 0;
|
|
337
|
+
var stats = { shellWrites: 0, mounts: 0 };
|
|
338
|
+
|
|
339
|
+
// Adopted-vocabulary state (design §7). All three are rebuilt by a restore,
|
|
340
|
+
// because all three are functions of the playhead — except `unknownTypes`,
|
|
341
|
+
// which describes the FILE and therefore outlives any walk.
|
|
342
|
+
var canvasNodes = new Map(); // node id -> {off, chain, w, h, dirty, sized, sizeCss}
|
|
343
|
+
var mediaState = new Map(); // node id -> {tag, state, time}
|
|
344
|
+
var unknownTypes = new Map(); // §5.8: type -> true, one entry per type
|
|
345
|
+
|
|
180
346
|
// Camera at the applied position: window scroll + viewport dims.
|
|
181
347
|
var cam = null; // {x, y, w, h, cw, ch}
|
|
182
|
-
|
|
183
|
-
|
|
348
|
+
// The §6 camera fields that describe WHAT THE PARTICIPANT SAW rather than
|
|
349
|
+
// where the page was: `dpr`, `vv_scale`, `vv_offset_x/y`. They are not
|
|
350
|
+
// alignment inputs — pinch zoom moves neither client coordinates nor
|
|
351
|
+
// `getBoundingClientRect`, and DPR moves neither — so nothing in the five
|
|
352
|
+
// §8 predicates reads them and nothing should. What they ARE is the
|
|
353
|
+
// difference between the analyst's view and the participant's, which is the
|
|
354
|
+
// same class as the DPR advisory and belongs in the same place. Tracked at
|
|
355
|
+
// the PLAYHEAD (an interaction recorded mid-pinch is a moment, not a
|
|
356
|
+
// segment), rebuilt by every restore like the rest of the walk's state.
|
|
357
|
+
var camView = null; // {dpr, scale, ox, oy} from the last event camera
|
|
358
|
+
var pendingCamSize = false;
|
|
359
|
+
var stageW = 0, stageH = 0;
|
|
184
360
|
var k = 1, ox = 0, oy = 0; // iframe scale + letterbox origin
|
|
185
361
|
|
|
186
|
-
|
|
362
|
+
// Scrub coalescing: at most one restore per animation frame, targeting the
|
|
363
|
+
// latest requested time. Without it a drag across a deep span queues one
|
|
364
|
+
// full span replay per `input` event (v1 seeked synchronously per event,
|
|
365
|
+
// which was affordable only because a v1 trial was self-contained).
|
|
366
|
+
var pendingSeek = null;
|
|
367
|
+
var frameQueued = false;
|
|
187
368
|
|
|
188
|
-
function
|
|
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
|
-
}
|
|
369
|
+
function seg() { return segments[segIdx]; }
|
|
196
370
|
|
|
197
371
|
// ── Header ──
|
|
198
372
|
var header = el('div', 'replay-header');
|
|
199
373
|
var badge = el('span', 'replay-badge', model.tier === 'dom' ? 'DOM replay' : 'Trace replay');
|
|
200
374
|
badge.setAttribute('data-tier', model.tier === 'dom' ? 'dom' : 'trace');
|
|
201
375
|
badge.setAttribute('role', 'status');
|
|
202
|
-
var
|
|
203
|
-
|
|
376
|
+
var segSel = el('select', 'replay-segment-select');
|
|
377
|
+
segments.forEach(function (s, i) {
|
|
204
378
|
var opt = el('option', null,
|
|
205
|
-
'
|
|
206
|
-
'
|
|
379
|
+
'Segment ' + (s.index != null ? s.index : i) +
|
|
380
|
+
' — ' + (s.label || (s.plugin ? s.plugin : '?')) +
|
|
381
|
+
' (' + fmtClock(s.durMs) + ')');
|
|
207
382
|
opt.value = String(i);
|
|
208
|
-
|
|
383
|
+
segSel.appendChild(opt);
|
|
209
384
|
});
|
|
210
385
|
var playBtn = el('button', 'replay-play', '▶');
|
|
211
386
|
playBtn.setAttribute('aria-label', 'Play');
|
|
@@ -224,66 +399,138 @@
|
|
|
224
399
|
autoAdvance = !pauseCheckbox.checked;
|
|
225
400
|
});
|
|
226
401
|
pauseLabel.appendChild(pauseCheckbox);
|
|
227
|
-
pauseLabel.appendChild(document.createTextNode(' Pause at
|
|
402
|
+
pauseLabel.appendChild(document.createTextNode(' Pause at segment boundaries'));
|
|
228
403
|
header.appendChild(badge);
|
|
229
|
-
header.appendChild(
|
|
404
|
+
header.appendChild(segSel);
|
|
230
405
|
header.appendChild(playBtn);
|
|
231
406
|
header.appendChild(speedSel);
|
|
232
407
|
header.appendChild(clock);
|
|
233
408
|
header.appendChild(sessionPos);
|
|
234
|
-
if (
|
|
409
|
+
if (segments.length > 1) header.appendChild(pauseLabel);
|
|
410
|
+
// Every CH panel is guarded on its OWN field, never on `foreign`: a
|
|
411
|
+
// converted file carries the CH namespace without CH's data, and a CH file
|
|
412
|
+
// captured standalone carries no score.
|
|
235
413
|
if (!model.scoring) {
|
|
236
414
|
header.appendChild(el('span', 'replay-note',
|
|
237
415
|
'No integrity score attached (this recording was captured standalone)'));
|
|
238
416
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
417
|
+
// ── §5.7: truncation, with the recording's own numbers ──
|
|
418
|
+
// The stop signal is an EVENT, emitted once into the segment open at stop
|
|
419
|
+
// time, and it carries `reason` — plus, when CH produced it, the configured
|
|
420
|
+
// cap it crossed, in the event's vendor namespace. It is found by one scan
|
|
421
|
+
// here rather than during a walk, because the banner is a property of the
|
|
422
|
+
// RECORDING and must be visible before the analyst seeks anywhere near the
|
|
423
|
+
// point where capture went dark.
|
|
424
|
+
function findCaptureStop() {
|
|
425
|
+
for (var si = 0; si < segments.length; si++) {
|
|
426
|
+
var evs = segments[si].events || [];
|
|
427
|
+
for (var ei = 0; ei < evs.length; ei++) {
|
|
428
|
+
if (evs[ei].type === 'recording.capture_stopped') return { seg: si, ev: evs[ei] };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
var captureStop = findCaptureStop();
|
|
434
|
+
|
|
435
|
+
function captureStopReason() {
|
|
436
|
+
var r = captureStop && captureStop.ev.reason;
|
|
437
|
+
if (r === 'buffer_limit') return 'buffer limit';
|
|
438
|
+
if (r === 'error') return 'capture error';
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// The cap the recording says it crossed, or null. CH stamps it as
|
|
443
|
+
// `{limit_events}` / `{limit_chars}`.
|
|
444
|
+
//
|
|
445
|
+
// The READ is deliberately NOT gated on `model.foreign`: §9 is where vendor
|
|
446
|
+
// data belongs, the read is guarded on its own presence, and a player that
|
|
447
|
+
// declines to read a namespace it understands is being fastidious at the
|
|
448
|
+
// analyst's expense. What IS gated on `foreign` is the FALLBACK below.
|
|
449
|
+
function captureStopLimit() {
|
|
450
|
+
var ext = captureStop && captureStop.ev.extensions;
|
|
451
|
+
var ch = ext && ext['cyborg-hunter'];
|
|
452
|
+
if (!ch) return null;
|
|
453
|
+
if (num(ch.limit_events) != null) return fmtCount(ch.limit_events) + ' events';
|
|
454
|
+
if (num(ch.limit_chars) != null) return fmtCount(ch.limit_chars) + ' characters';
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (model.captureStopped || model.truncated || captureStop) {
|
|
258
459
|
var capDetails = document.createElement('details');
|
|
259
460
|
capDetails.className = 'replay-note replay-warn replay-cap';
|
|
260
461
|
capDetails.setAttribute('data-ch-cap-note', '');
|
|
261
462
|
var capSummary = document.createElement('summary');
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
463
|
+
var reason = captureStopReason();
|
|
464
|
+
capSummary.textContent = 'Capture stopped before the session ended' +
|
|
465
|
+
(reason ? ' (' + reason + ')' : '') + ' (details)';
|
|
466
|
+
var capText = 'The recorder stopped capturing before this session finished, so the replay ' +
|
|
467
|
+
'ends earlier than the participant\'s session did. ';
|
|
468
|
+
if (captureStop) {
|
|
469
|
+
capText += 'It stopped during segment ' +
|
|
470
|
+
(segments[captureStop.seg].index != null ? segments[captureStop.seg].index : captureStop.seg) +
|
|
471
|
+
' at ' + fmtClock(num(captureStop.ev.t) || 0) + '. ';
|
|
472
|
+
}
|
|
473
|
+
// What the banner may say about the CAUSE, in the order the recording
|
|
474
|
+
// constrains it. Every branch below says only what this file supports:
|
|
475
|
+
// a banner that surfaces truncation with the wrong cause is a worse
|
|
476
|
+
// failure than the interim wording it replaced.
|
|
477
|
+
var limit = captureStopLimit();
|
|
478
|
+
var reasonRaw = captureStop ? captureStop.ev.reason : null;
|
|
479
|
+
if (limit) {
|
|
480
|
+
capText += 'This recording states the cap it crossed: the recorder was configured to stop ' +
|
|
481
|
+
'a segment after ' + limit + '. ';
|
|
482
|
+
} else if (reasonRaw === 'error') {
|
|
483
|
+
// The recording DENIES the buffer cap. Naming "capture error" in the
|
|
484
|
+
// summary and then explaining it as a cap in the body — quoting two
|
|
485
|
+
// numbers with nothing to do with what happened — is the banner
|
|
486
|
+
// contradicting itself inside one <details>.
|
|
487
|
+
capText += 'The recording attributes the stop to a capture error rather than a buffer cap, ' +
|
|
488
|
+
'and says nothing further about it. ';
|
|
489
|
+
} else if (model.foreign) {
|
|
490
|
+
// CAP_DEFAULTS are THIS library's REPLAY_DEFAULTS. Quoting them in the
|
|
491
|
+
// sentence that explains another producer's stop describes a recorder
|
|
492
|
+
// that did not make the file. `model.foreign` keys on producer identity
|
|
493
|
+
// (viewer-model.js) and exists for exactly this.
|
|
494
|
+
capText += 'This file does not state the cap it crossed, and it was not produced by this ' +
|
|
495
|
+
'library, so no configured limit can be quoted for it. ';
|
|
496
|
+
} else {
|
|
497
|
+
capText += 'The usual cause is a buffer cap: the recorder caps each segment at about ' +
|
|
498
|
+
fmtCount(CAP_DEFAULTS.events) + ' events or ' + fmtCount(CAP_DEFAULTS.chars) +
|
|
499
|
+
' characters (library defaults — both are configurable when the recorder is attached, ' +
|
|
500
|
+
'and this file does not state which cap it crossed). ';
|
|
501
|
+
}
|
|
502
|
+
capText += 'Absence of evidence after this point is not evidence of absence.';
|
|
272
503
|
capDetails.appendChild(capSummary);
|
|
273
|
-
capDetails.appendChild(
|
|
504
|
+
capDetails.appendChild(el('p', null, capText));
|
|
274
505
|
header.appendChild(capDetails);
|
|
275
506
|
}
|
|
507
|
+
|
|
508
|
+
// A capture CHANNEL that threw is spec §13's absence-of-evidence case in
|
|
509
|
+
// its sharpest form: the session looks clean in exactly the dimension that
|
|
510
|
+
// stopped being observed. `buildViewerModel` reduces the recorder's
|
|
511
|
+
// `{channel, message, t}` records to channel names, which is what a chip
|
|
512
|
+
// can say; the messages are recorder diagnostics with no analyst-facing
|
|
513
|
+
// reading. (T5.1 M-3: this field had no consumer until here.)
|
|
514
|
+
var captureFailChip = el('span', 'replay-note replay-warn', '');
|
|
515
|
+
captureFailChip.setAttribute('data-ch-capture-failures', '');
|
|
516
|
+
captureFailChip.style.display = 'none';
|
|
517
|
+
var failedChannels = (model.captureFailures || []).filter(function (c) { return !!c; });
|
|
518
|
+
if (failedChannels.length > 0) {
|
|
519
|
+
captureFailChip.textContent = '⚠ ' + failedChannels.length + ' capture channel(s) failed ' +
|
|
520
|
+
'during recording (' + failedChannels.join(', ') + '). Evidence those channels would have ' +
|
|
521
|
+
'carried is missing from this replay.';
|
|
522
|
+
captureFailChip.style.display = '';
|
|
523
|
+
}
|
|
524
|
+
header.appendChild(captureFailChip);
|
|
276
525
|
// Alignment status chips — the "never fail silently" surface.
|
|
277
526
|
var alignChip = el('span', 'replay-note', '');
|
|
278
527
|
alignChip.setAttribute('role', 'status');
|
|
279
528
|
alignChip.setAttribute('data-ch-align', '');
|
|
280
529
|
header.appendChild(alignChip);
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
header.appendChild(legacyChip);
|
|
286
|
-
}
|
|
530
|
+
var defectChip = el('span', 'replay-note replay-warn', '');
|
|
531
|
+
defectChip.setAttribute('data-ch-defect', '');
|
|
532
|
+
defectChip.style.display = 'none';
|
|
533
|
+
header.appendChild(defectChip);
|
|
287
534
|
var iframeChip = el('span', 'replay-note replay-warn',
|
|
288
535
|
'This recording contains iframe content that was not captured. Interactions inside it are not visible here.');
|
|
289
536
|
iframeChip.setAttribute('data-ch-iframe-warn', '');
|
|
@@ -300,11 +547,43 @@
|
|
|
300
547
|
header.appendChild(failChip);
|
|
301
548
|
// Declared-limitation advisories (loud, not blocking): DPR-conditional
|
|
302
549
|
// styling cannot be reproduced inside an iframe; CSS animations run on
|
|
303
|
-
// viewer time.
|
|
550
|
+
// viewer time.
|
|
304
551
|
var dprChip = el('span', 'replay-note', '');
|
|
305
552
|
dprChip.setAttribute('data-ch-dpr-note', '');
|
|
306
553
|
dprChip.style.display = 'none';
|
|
307
554
|
header.appendChild(dprChip);
|
|
555
|
+
// Pinch/zoom state at the playhead. A pinched participant is looking at a
|
|
556
|
+
// magnified sub-rectangle of the same layout, so the reconstruction is
|
|
557
|
+
// geometrically right and visually wrong, and only saying so makes the
|
|
558
|
+
// difference visible.
|
|
559
|
+
var zoomChip = el('span', 'replay-note', '');
|
|
560
|
+
zoomChip.setAttribute('data-ch-zoom-note', '');
|
|
561
|
+
zoomChip.style.display = 'none';
|
|
562
|
+
header.appendChild(zoomChip);
|
|
563
|
+
// Media is rendered as STATE, never played (design §7) — the forensic
|
|
564
|
+
// posture, and the fork's too. Saying so is part of the rendering: a badge
|
|
565
|
+
// reading "paused at 4.0s" over a silent element would otherwise read as a
|
|
566
|
+
// viewer that failed rather than one that declined.
|
|
567
|
+
var mediaChip = el('span', 'replay-note', '');
|
|
568
|
+
mediaChip.setAttribute('data-ch-media-note', '');
|
|
569
|
+
mediaChip.style.display = 'none';
|
|
570
|
+
var hasMedia = segments.some(function (s) {
|
|
571
|
+
return (s.events || []).some(function (e) { return e.type && e.type.indexOf('media.') === 0; }) ||
|
|
572
|
+
!!(s.initialState && (s.initialState.media || []).length > 0);
|
|
573
|
+
});
|
|
574
|
+
if (hasMedia) {
|
|
575
|
+
mediaChip.textContent = 'This recording contains audio/video state. The viewer shows play state and position as badges; it does not play back media.';
|
|
576
|
+
mediaChip.style.display = '';
|
|
577
|
+
}
|
|
578
|
+
header.appendChild(mediaChip);
|
|
579
|
+
// §5.8: a type this viewer does not know is SKIPPED, never guessed at, and
|
|
580
|
+
// never silently. Counted once per type — the walk replays on every
|
|
581
|
+
// restore, so an occurrence count would climb with the analyst's scrubbing
|
|
582
|
+
// rather than describe the file.
|
|
583
|
+
var unknownChip = el('span', 'replay-note', '');
|
|
584
|
+
unknownChip.setAttribute('data-ch-unknown-types', '');
|
|
585
|
+
unknownChip.style.display = 'none';
|
|
586
|
+
header.appendChild(unknownChip);
|
|
308
587
|
var animChip = el('span', 'replay-note', '');
|
|
309
588
|
animChip.setAttribute('data-ch-anim-note', '');
|
|
310
589
|
animChip.style.display = 'none';
|
|
@@ -317,9 +596,13 @@
|
|
|
317
596
|
}
|
|
318
597
|
header.appendChild(animChip);
|
|
319
598
|
|
|
320
|
-
var allowExternalCss =
|
|
599
|
+
var allowExternalCss = initialExternalCss;
|
|
321
600
|
var omittedSheets = (model.stylesheets || []).filter(function (sh) { return !sh.css; }).length;
|
|
322
|
-
if (omittedSheets > 0) {
|
|
601
|
+
if (omittedSheets > 0 && allowExternalCss) {
|
|
602
|
+
var fetchedNote = el('span', 'replay-note',
|
|
603
|
+
omittedSheets + ' external stylesheet(s) are fetched from their origins (network requests leave this machine).');
|
|
604
|
+
header.appendChild(fetchedNote);
|
|
605
|
+
} else if (omittedSheets > 0) {
|
|
323
606
|
var cssNote = el('span', 'replay-note',
|
|
324
607
|
omittedSheets + ' external stylesheet(s) were not loaded. Layout may differ. ');
|
|
325
608
|
var cssBtn = el('button', 'replay-css-btn', 'Load external CSS');
|
|
@@ -328,7 +611,9 @@
|
|
|
328
611
|
allowExternalCss = true;
|
|
329
612
|
cssBtn.remove();
|
|
330
613
|
cssNote.textContent = omittedSheets + ' external stylesheet(s) loaded from their origins.';
|
|
331
|
-
|
|
614
|
+
// The CSP string itself changes, so this is the one path that still
|
|
615
|
+
// needs a new document — and the one place `onload` survives.
|
|
616
|
+
if (iframe) writeShell();
|
|
332
617
|
});
|
|
333
618
|
cssNote.appendChild(cssBtn);
|
|
334
619
|
header.appendChild(cssNote);
|
|
@@ -340,15 +625,17 @@
|
|
|
340
625
|
var iframe = null;
|
|
341
626
|
if (model.tier === 'dom') {
|
|
342
627
|
iframe = document.createElement('iframe');
|
|
343
|
-
// allow-same-origin WITHOUT allow-scripts: scripts stay blocked (both
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
628
|
+
// allow-same-origin WITHOUT allow-scripts: scripts stay blocked (both by
|
|
629
|
+
// the sandbox and by the srcdoc CSP), but the document keeps a reachable
|
|
630
|
+
// origin so the player can instantiate and patch through contentDocument.
|
|
631
|
+
// sandbox="" would make the origin opaque and freeze every dom-tier
|
|
632
|
+
// replay at its first frame. allow-scripts is rejected outright: with
|
|
633
|
+
// allow-same-origin it lets framed content remove its own sandbox
|
|
634
|
+
// attribute, which is not a sandbox.
|
|
348
635
|
iframe.setAttribute('sandbox', 'allow-same-origin');
|
|
349
636
|
iframe.className = 'replay-frame';
|
|
350
|
-
// Non-interactive: an analyst click inside the reconstruction would
|
|
351
|
-
//
|
|
637
|
+
// Non-interactive: an analyst click inside the reconstruction would act
|
|
638
|
+
// on live untrusted DOM (submit a form, navigate the frame).
|
|
352
639
|
iframe.style.pointerEvents = 'none';
|
|
353
640
|
iframe.setAttribute('tabindex', '-1');
|
|
354
641
|
iframe.style.transformOrigin = '0 0';
|
|
@@ -363,14 +650,38 @@
|
|
|
363
650
|
overlay.className = 'replay-overlay';
|
|
364
651
|
overlay.setAttribute('aria-hidden', 'true');
|
|
365
652
|
stage.appendChild(overlay);
|
|
653
|
+
// Unstyled-stage banner: shown whenever a sheet the recording names is
|
|
654
|
+
// not in the frame — skipped (strict frame, opt-in not taken) or failed
|
|
655
|
+
// to load (offline, URL gone, server refused). ON the stage, because an
|
|
656
|
+
// unstyled page is misaligned by construction and a header note above
|
|
657
|
+
// it went unread through a whole session (bench harness, 2026-09-03).
|
|
658
|
+
var unstyledBanner = el('div', 'replay-unstyled', '');
|
|
659
|
+
unstyledBanner.setAttribute('data-ch-unstyled', '');
|
|
660
|
+
unstyledBanner.setAttribute('role', 'status');
|
|
661
|
+
unstyledBanner.style.display = 'none';
|
|
662
|
+
stage.appendChild(unstyledBanner);
|
|
663
|
+
var missingSheets = 0, failedSheets = 0;
|
|
664
|
+
function updateUnstyled() {
|
|
665
|
+
var n = missingSheets + failedSheets;
|
|
666
|
+
if (n === 0) { unstyledBanner.style.display = 'none'; unstyledBanner.textContent = ''; return; }
|
|
667
|
+
unstyledBanner.textContent = 'Unstyled replay — ' + n + ' stylesheet' + (n === 1 ? '' : 's') +
|
|
668
|
+
(failedSheets ? ' could not be fetched' : ' not loaded') +
|
|
669
|
+
'; layout differs from the recorded page, so cursor positions will not match elements.' +
|
|
670
|
+
(failedSheets ? '' : ' Reload the replay with “fetch external stylesheets” ticked, or use the button above.');
|
|
671
|
+
unstyledBanner.style.display = '';
|
|
672
|
+
}
|
|
366
673
|
// Keycast: chips for keys currently down or recently released, mirroring
|
|
367
|
-
// the click-ripple/cursor-trail convention
|
|
368
|
-
//
|
|
369
|
-
// recordings carry no keydown/keyup events, so this stays empty; that
|
|
674
|
+
// the click-ripple/cursor-trail convention (a pure function of playhead).
|
|
675
|
+
// keys:'off' recordings carry no key events, so this stays empty; that
|
|
370
676
|
// absence IS the honest signal, no separate note needed.
|
|
371
677
|
var keycast = el('div', 'replay-keycast');
|
|
372
678
|
keycast.setAttribute('aria-hidden', 'true');
|
|
373
679
|
stage.appendChild(keycast);
|
|
680
|
+
// Media badges: one per element with recorded state, top-left of the stage,
|
|
681
|
+
// the same "pure function of playhead" convention as the keycast chips.
|
|
682
|
+
var mediaLayer = el('div', 'replay-media');
|
|
683
|
+
mediaLayer.setAttribute('aria-hidden', 'true');
|
|
684
|
+
stage.appendChild(mediaLayer);
|
|
374
685
|
mount.appendChild(stage);
|
|
375
686
|
var ctx = overlay.getContext('2d');
|
|
376
687
|
|
|
@@ -392,15 +703,263 @@
|
|
|
392
703
|
var ticker = el('div', 'replay-ticker', '');
|
|
393
704
|
mount.appendChild(ticker);
|
|
394
705
|
|
|
395
|
-
// ──
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
706
|
+
// ── The shell, written once ──
|
|
707
|
+
function frameDoc() {
|
|
708
|
+
return iframe && shellReady ? iframe.contentDocument : null;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// The sentinel carries the GENERATION of the write that produced it, and
|
|
712
|
+
// that is what makes the boot document-scoped rather than merely
|
|
713
|
+
// idempotent. Existence alone is not enough: every shell carries an
|
|
714
|
+
// identical sentinel, so on a SECOND write the immediate probe below finds
|
|
715
|
+
// the PREVIOUS document (browsers navigate `srcdoc` asynchronously, so
|
|
716
|
+
// `contentDocument` still holds the old one) and would boot into a
|
|
717
|
+
// document the browser is about to discard. That blanks the
|
|
718
|
+
// reconstruction, leaves every id in the span map pointing into a detached
|
|
719
|
+
// tree, and leaves `frameReady()` reporting true over an empty frame —
|
|
720
|
+
// measured in chromium, and the reason v1 carried a generation counter at
|
|
721
|
+
// all. The counter left with the per-seek rebuild; the external-CSS
|
|
722
|
+
// rewrite that still needs one stayed.
|
|
723
|
+
function shellPresent() {
|
|
724
|
+
var doc = iframe && iframe.contentDocument;
|
|
725
|
+
try {
|
|
726
|
+
var meta = doc && doc.querySelector('meta[name="' + SHELL_META + '"]');
|
|
727
|
+
return !!meta && meta.getAttribute('content') === String(shellGen);
|
|
728
|
+
} catch (e) { return false; }
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Browsers navigate the frame asynchronously and fire `load`; happy-dom
|
|
732
|
+
// parses `srcdoc` synchronously and fires `load` afterwards, twice. One
|
|
733
|
+
// idempotent boot covers all of it: try immediately, and again on load.
|
|
734
|
+
// The immediate try is what keeps the node suite synchronous; the `onload`
|
|
735
|
+
// install is what carries every real browser, and only the generation
|
|
736
|
+
// check above keeps the two from disagreeing about which document is live.
|
|
737
|
+
function writeShell() {
|
|
738
|
+
shellReady = false;
|
|
739
|
+
stats.shellWrites++;
|
|
740
|
+
shellGen++;
|
|
741
|
+
iframe.onload = onShellLoad;
|
|
742
|
+
iframe.srcdoc = buildShell(allowExternalCss, shellGen);
|
|
743
|
+
bootShell();
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function onShellLoad() { bootShell(); }
|
|
747
|
+
|
|
748
|
+
function bootShell() {
|
|
749
|
+
if (shellReady || !shellPresent()) return;
|
|
750
|
+
shellReady = true;
|
|
751
|
+
restore(segIdx, playhead);
|
|
752
|
+
redraw();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ── Stylesheet state (spec §2 / design §5 step 2) ──
|
|
756
|
+
// Stylesheet state at a keyframe is DERIVED, never seeded: the session
|
|
757
|
+
// baseline plus every `stylesheet_events` entry up to the keyframe origin.
|
|
758
|
+
// Rebuilding from the baseline on each restore is what makes a backward
|
|
759
|
+
// seek across a `stylesheet.remove` bring the sheet back; a running
|
|
760
|
+
// mutation would not.
|
|
761
|
+
function sheetNode(doc, sheet) {
|
|
762
|
+
var node;
|
|
763
|
+
if (sheet.css != null) {
|
|
764
|
+
node = doc.createElement('style');
|
|
765
|
+
node.textContent = sheet.css;
|
|
766
|
+
} else if (allowExternalCss && sheet.href && /^https?:/i.test(sheet.href)) {
|
|
767
|
+
node = doc.createElement('link');
|
|
768
|
+
node.setAttribute('rel', 'stylesheet');
|
|
769
|
+
node.setAttribute('href', sheet.href);
|
|
770
|
+
// A blocked or failed fetch fires `error` on the element (parent-realm
|
|
771
|
+
// handler; the frame runs no scripts). Counted once per node.
|
|
772
|
+
node.onerror = function () { failedSheets++; updateUnstyled(); };
|
|
773
|
+
} else {
|
|
774
|
+
missingSheets++; // external sheet, opt-in not taken
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
node.setAttribute('data-ch-sheet', String(sheet.id));
|
|
778
|
+
if (sheet.media) node.setAttribute('media', sheet.media);
|
|
779
|
+
return node;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Sheets go BEFORE the viewer's own rules so a recording's CSS can never
|
|
783
|
+
// override the gutter fix or the placeholder outline.
|
|
784
|
+
//
|
|
785
|
+
// EVERY SHEET QUERY IN THIS SECTION IS HEAD-SCOPED, and that scoping is
|
|
786
|
+
// load-bearing rather than tidiness. `data-ch-sheet` is a name the viewer
|
|
787
|
+
// stamps and then reads back, which puts it in the same class as the two
|
|
788
|
+
// the §12 predicate protects — but a recording can carry it too, and a
|
|
789
|
+
// document-wide read cannot tell the viewer's sheet from a page element.
|
|
790
|
+
// Measured consequence of the unscoped version: `deriveSheets`'s reset loop
|
|
791
|
+
// DELETED a keyframe element carrying the attribute, on every restore, with
|
|
792
|
+
// both counters at zero, so the reconstruction diverged silently while
|
|
793
|
+
// `resolveNode` still resolved the id. Recorded content only ever mounts
|
|
794
|
+
// into <body> and sheets only ever live in <head>, so scoping the read is
|
|
795
|
+
// what closes it — and unlike widening the attribute ban, it deletes
|
|
796
|
+
// nothing the page really had.
|
|
797
|
+
function insertSheet(doc, sheet) {
|
|
798
|
+
var node = sheetNode(doc, sheet);
|
|
799
|
+
if (!node) return;
|
|
800
|
+
var anchor = doc.head.querySelector('style[data-ch-shell-rules]');
|
|
801
|
+
doc.head.insertBefore(node, anchor || null);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function applySheetEvent(doc, ev) {
|
|
805
|
+
if (ev.type === 'stylesheet.add') {
|
|
806
|
+
if (ev.sheet && ev.sheet.id != null) {
|
|
807
|
+
removeSheet(doc, ev.sheet.id);
|
|
808
|
+
insertSheet(doc, ev.sheet);
|
|
809
|
+
}
|
|
810
|
+
} else if (ev.type === 'stylesheet.remove') {
|
|
811
|
+
removeSheet(doc, ev.id);
|
|
812
|
+
} else if (ev.type === 'stylesheet.update') {
|
|
813
|
+
var node = doc.head.querySelector('[data-ch-sheet="' + ev.id + '"]');
|
|
814
|
+
if (node && node.tagName.toLowerCase() === 'style') node.textContent = ev.css == null ? '' : ev.css;
|
|
815
|
+
else insertSheet(doc, { id: ev.id, kind: 'inline', css: ev.css, media: null });
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function removeSheet(doc, id) {
|
|
820
|
+
var node = doc.head.querySelector('[data-ch-sheet="' + id + '"]');
|
|
821
|
+
if (node && node.parentNode) node.parentNode.removeChild(node);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function deriveSheets(doc, originT) {
|
|
825
|
+
var live = doc.head.querySelectorAll('[data-ch-sheet]');
|
|
826
|
+
for (var i = live.length - 1; i >= 0; i--) {
|
|
827
|
+
if (live[i].parentNode) live[i].parentNode.removeChild(live[i]);
|
|
828
|
+
}
|
|
829
|
+
missingSheets = 0; failedSheets = 0; // recounted by every rebuild
|
|
830
|
+
(model.stylesheets || []).forEach(function (sheet) {
|
|
831
|
+
if (sheet && sheet.id != null) insertSheet(doc, sheet);
|
|
832
|
+
});
|
|
833
|
+
for (var j = 0; j < sheetEvents.length; j++) {
|
|
834
|
+
var ev = sheetEvents[j];
|
|
835
|
+
if ((num(ev.t) || 0) > originT) break;
|
|
836
|
+
applySheetEvent(doc, ev);
|
|
837
|
+
}
|
|
838
|
+
updateUnstyled();
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ── Span geometry ──
|
|
842
|
+
function spanStartOf(i) {
|
|
843
|
+
var s = segments[i].spanStart;
|
|
844
|
+
return s == null ? -1 : s;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function spanEndOf(start) {
|
|
848
|
+
var i = start + 1;
|
|
849
|
+
while (i < segments.length && !segments[i].keyframe) i++;
|
|
850
|
+
return i - 1;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// The merged, ordered entry list for one span (design §5 step 4). Session
|
|
854
|
+
// streams are assigned to the segment whose window contains them —
|
|
855
|
+
// segments are ordered and non-overlapping (§3), so `[origin_i,
|
|
856
|
+
// origin_{i+1})` is a partition — and rebased into that segment's frame
|
|
857
|
+
// with the same forward formula the model used for its events.
|
|
858
|
+
function buildWalk(start, end) {
|
|
859
|
+
var spanOrigin = segments[start].origin;
|
|
860
|
+
var out = [];
|
|
861
|
+
for (var i = start; i <= end; i++) {
|
|
862
|
+
var s = segments[i];
|
|
863
|
+
var from = s.origin;
|
|
864
|
+
var to = i + 1 < segments.length ? segments[i + 1].origin : Infinity;
|
|
865
|
+
var rows = [];
|
|
866
|
+
var pushStream = function (list, rank, stream) {
|
|
867
|
+
for (var n = 0; n < list.length; n++) {
|
|
868
|
+
var t = num(list[n].t) || 0;
|
|
869
|
+
// `> spanOrigin` is the derivation boundary: everything at or
|
|
870
|
+
// before the keyframe origin is already folded into the mounted
|
|
871
|
+
// state, so replaying it here would apply it twice.
|
|
872
|
+
if (t <= spanOrigin || t < from || t >= to) continue;
|
|
873
|
+
rows.push({ t: round1(t - from), rank: rank, stream: stream, seg: i, payload: list[n] });
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
pushStream(sheetEvents, RANK_SHEET, 'stylesheet');
|
|
877
|
+
pushStream(viewportChanges, RANK_VIEWPORT, 'viewport');
|
|
878
|
+
for (var e = 0; e < s.events.length; e++) {
|
|
879
|
+
rows.push({
|
|
880
|
+
t: num(s.events[e].t) || 0, rank: RANK_EVENT, stream: 'event',
|
|
881
|
+
seg: i, payload: s.events[e]
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
rows.sort(function (a, b) { return a.t - b.t || a.rank - b.rank; });
|
|
885
|
+
for (var r = 0; r < rows.length; r++) out.push(rows[r]);
|
|
886
|
+
}
|
|
887
|
+
return out;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ── Camera ──
|
|
891
|
+
// v1 folded a per-trial view-state seed plus in-stream `resize` events. v2
|
|
892
|
+
// has neither: the seed is the model's per-segment camera (session
|
|
893
|
+
// `viewport_changes` folded to the segment origin, window scroll from the
|
|
894
|
+
// span keyframe's `initial_state`), and the in-stream updates are session
|
|
895
|
+
// `viewport_changes` inside the walk plus per-event §6 camera blocks.
|
|
896
|
+
function seedCamera(i) {
|
|
897
|
+
var seed = segments[i].camera || {};
|
|
898
|
+
var w = num(seed.w) != null ? seed.w : 1280;
|
|
899
|
+
var h = num(seed.h) != null ? seed.h : 800;
|
|
900
|
+
cam = {
|
|
901
|
+
x: num(seed.scroll_x) || 0, y: num(seed.scroll_y) || 0,
|
|
902
|
+
w: w, h: h,
|
|
903
|
+
cw: num(seed.client_w) != null ? seed.client_w : w,
|
|
904
|
+
ch: num(seed.client_h) != null ? seed.client_h : h
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function foldViewport(c) {
|
|
909
|
+
if (num(c.w) != null) { cam.w = c.w; cam.cw = c.w - (scrollbar.w || 0); }
|
|
910
|
+
if (num(c.h) != null) { cam.h = c.h; cam.ch = c.h - (scrollbar.h || 0); }
|
|
911
|
+
pendingCamSize = true;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// A §6 camera block on the interaction is AUTHORITATIVE, for the reason v1
|
|
915
|
+
// documented: scroll and resize notifications dispatch after the
|
|
916
|
+
// programmatic change, so the snapshot on the interaction can know state
|
|
917
|
+
// whose notification arrives later in the stream (and then applies as a
|
|
918
|
+
// no-op). `client_w`/`client_h` are level 1 of design §8's client-box
|
|
919
|
+
// chain — the only level that is per-event.
|
|
920
|
+
function foldEventCamera(c) {
|
|
921
|
+
if (num(c.scroll_x) != null && (c.scroll_x !== cam.x || c.scroll_y !== cam.y)) {
|
|
922
|
+
cam.x = c.scroll_x; cam.y = num(c.scroll_y) || 0;
|
|
923
|
+
applyCamScroll();
|
|
924
|
+
}
|
|
925
|
+
if (num(c.viewport_w) != null) cam.w = c.viewport_w;
|
|
926
|
+
if (num(c.viewport_h) != null) cam.h = c.viewport_h;
|
|
927
|
+
if (num(c.client_w) != null && (c.client_w !== cam.cw || c.client_h !== cam.ch)) {
|
|
928
|
+
cam.cw = c.client_w;
|
|
929
|
+
cam.ch = num(c.client_h) != null ? c.client_h : cam.ch;
|
|
930
|
+
pendingCamSize = true;
|
|
931
|
+
}
|
|
932
|
+
// The remaining three §6 fields. Nothing here changes the reconstruction:
|
|
933
|
+
// they are read so the viewer can SAY what the participant's own view was
|
|
934
|
+
// at this moment (see `camView`).
|
|
935
|
+
//
|
|
936
|
+
// Every field falls back to the PREVIOUS reading, symmetrically. §6's
|
|
937
|
+
// "complete blocks or none, no delta encoding" makes a partial block
|
|
938
|
+
// unreachable from a conforming producer, but this viewer plays foreign
|
|
939
|
+
// files by design, and an asymmetric fallback would let a block stating
|
|
940
|
+
// only `dpr` cancel a live pinch note while preserving the DPR one —
|
|
941
|
+
// inventing an end to a state the recording never said had ended. A
|
|
942
|
+
// field nobody has ever stated stays null/1/0. (T5.8 fix, review M-3.)
|
|
943
|
+
if (num(c.dpr) != null || num(c.vv_scale) != null ||
|
|
944
|
+
num(c.vv_offset_x) != null || num(c.vv_offset_y) != null) {
|
|
945
|
+
var prev = camView || { dpr: null, scale: 1, ox: 0, oy: 0 };
|
|
946
|
+
camView = {
|
|
947
|
+
dpr: num(c.dpr) != null ? c.dpr : prev.dpr,
|
|
948
|
+
scale: num(c.vv_scale) != null ? c.vv_scale : prev.scale,
|
|
949
|
+
ox: num(c.vv_offset_x) != null ? c.vv_offset_x : prev.ox,
|
|
950
|
+
oy: num(c.vv_offset_y) != null ? c.vv_offset_y : prev.oy
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// The stage BOX is fixed per segment (sized from the seed camera's aspect)
|
|
956
|
+
// so playback never reflows the report page; camera changes re-letterbox
|
|
957
|
+
// the iframe INSIDE the box.
|
|
399
958
|
function sizeStage() {
|
|
400
959
|
stageW = Math.min(mount.clientWidth || 720, 960);
|
|
401
|
-
var seed =
|
|
402
|
-
var scw = seed.
|
|
403
|
-
var sch = seed.
|
|
960
|
+
var seed = seg().camera || {};
|
|
961
|
+
var scw = num(seed.client_w) || num(seed.w) || 1280;
|
|
962
|
+
var sch = num(seed.client_h) || num(seed.h) || 800;
|
|
404
963
|
stageH = Math.round(sch * (stageW / scw));
|
|
405
964
|
stage.style.width = stageW + 'px';
|
|
406
965
|
stage.style.height = stageH + 'px';
|
|
@@ -410,10 +969,8 @@
|
|
|
410
969
|
scrub.style.width = stageW + 'px';
|
|
411
970
|
}
|
|
412
971
|
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
// batch — a 240-event resize storm costs a handful of real reflows, not
|
|
416
|
-
// 240 (Sol round-1 finding 11).
|
|
972
|
+
// Applied lazily: before each anchored self-check and once per applied
|
|
973
|
+
// batch, so a viewport-change storm costs a handful of real reflows.
|
|
417
974
|
function flushCamSize() {
|
|
418
975
|
pendingCamSize = false;
|
|
419
976
|
// Scale + letterbox apply to the OVERLAY too — trace-tier replays have
|
|
@@ -430,268 +987,767 @@
|
|
|
430
987
|
}
|
|
431
988
|
|
|
432
989
|
function applyCamScroll() {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
if (doc
|
|
990
|
+
var doc = frameDoc();
|
|
991
|
+
if (!doc) return;
|
|
992
|
+
if (doc.defaultView && typeof doc.defaultView.scrollTo === 'function') {
|
|
436
993
|
try { doc.defaultView.scrollTo(cam.x, cam.y); } catch (e) { /* best-effort */ }
|
|
437
994
|
}
|
|
438
995
|
}
|
|
439
996
|
|
|
440
|
-
// ──
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
997
|
+
// ── Placeholders and unrecordable regions (spec §12/§13) ──
|
|
998
|
+
// LATCHED per segment selection: an iframe or shadow host that existed at
|
|
999
|
+
// ANY point keeps its warning even after a removal patch — the invisible
|
|
1000
|
+
// interactions do not become visible because the element left.
|
|
1001
|
+
//
|
|
1002
|
+
// The query is sound because the `data-ch-*` names the viewer stamps are
|
|
1003
|
+
// viewer-owned in BOTH directions (dom-instantiate.js): a recording can
|
|
1004
|
+
// neither forge one nor strip one. Hiding the shadow chip would be a §13
|
|
1005
|
+
// "absence of evidence" signal going dark, which is a worse outcome than
|
|
1006
|
+
// any forged outline.
|
|
1007
|
+
var segHadIframe = false;
|
|
1008
|
+
var segHadShadow = false;
|
|
1009
|
+
function updatePlaceholderChips() {
|
|
1010
|
+
var doc = frameDoc();
|
|
1011
|
+
try {
|
|
1012
|
+
if (doc && doc.querySelector('iframe, [data-ch-placeholder="iframe"]')) segHadIframe = true;
|
|
1013
|
+
if (doc && doc.querySelector('[data-ch-shadow]')) segHadShadow = true;
|
|
1014
|
+
} catch (e) { /* keep latches */ }
|
|
1015
|
+
iframeChip.style.display = segHadIframe ? '' : 'none';
|
|
1016
|
+
shadowChip.style.display = segHadShadow ? '' : 'none';
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// ── Canvas compositing and presentation (design §3) ──
|
|
1020
|
+
//
|
|
1021
|
+
// THE MEASURED FACT THIS IS BUILT AROUND (Task 0, tri-engine): a canvas in a
|
|
1022
|
+
// frame sandboxed WITHOUT allow-scripts accepts `getContext('2d')`, accepts
|
|
1023
|
+
// the draw calls, holds correct pixels — and never paints them. So the
|
|
1024
|
+
// composite happens in an offscreen canvas owned by the REPORT document,
|
|
1025
|
+
// where painting is unrestricted, and the result is PRESENTED as a
|
|
1026
|
+
// background image on the in-frame canvas. Nothing about the recorded tree
|
|
1027
|
+
// changes: same tag, same id, same attributes, same CSS match.
|
|
1028
|
+
//
|
|
1029
|
+
// ROUTE 2 OF DESIGN §3.1, taken deliberately over route 1 (re-present after
|
|
1030
|
+
// the clobber). The presentation is a rule in the SHELL HEAD keyed on a
|
|
1031
|
+
// viewer-owned attribute, not a declaration in the element's inline style.
|
|
1032
|
+
// Per CSSOM `setAttribute('style', …)` replaces the whole inline
|
|
1033
|
+
// declaration block, so a recorded `dom.attr` naming `style` — or removing
|
|
1034
|
+
// it with `value: null` — erases anything the viewer put there; measured on
|
|
1035
|
+
// all three engines, and mandated by the spec rather than produced by it.
|
|
1036
|
+
// Presenting through the head makes the hazard structurally absent instead
|
|
1037
|
+
// of repaired after the fact, and it covers the removal case for free. It
|
|
1038
|
+
// costs one stamped attribute and one <style> per composited canvas; the
|
|
1039
|
+
// attribute is viewer-owned in dom-instantiate.js, so a recording can
|
|
1040
|
+
// neither forge the selector nor strip it.
|
|
1041
|
+
//
|
|
1042
|
+
// COST. Compositing is per-event and cheap (`drawImage`); PRESENTATION is a
|
|
1043
|
+
// PNG re-encode and is deferred to once per applied batch, per canvas a
|
|
1044
|
+
// snapshot actually touched — Task 0 measured that presented SIZE, not
|
|
1045
|
+
// snapshot count, is what the cost tracks (ten composites presented cheaper
|
|
1046
|
+
// than one 189 KB baseline).
|
|
1047
|
+
function resetCanvases(doc) {
|
|
1048
|
+
canvasNodes = new Map();
|
|
1049
|
+
if (!doc) return;
|
|
1050
|
+
// HEAD-scoped, like every other read of a name the viewer stamps: the
|
|
1051
|
+
// recording mounts into <body>, and an unscoped query cannot tell the
|
|
1052
|
+
// viewer's own element from a page element carrying the same attribute
|
|
1053
|
+
// (the failure T5.4's I-1 found for `data-ch-sheet`).
|
|
1054
|
+
var live = doc.head.querySelectorAll('[' + CANVAS_RULE_ATTR + ']');
|
|
1055
|
+
for (var i = live.length - 1; i >= 0; i--) {
|
|
1056
|
+
if (live[i].parentNode) live[i].parentNode.removeChild(live[i]);
|
|
454
1057
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// The bitmap size is §4's `canvas_size` annotation, recorded at
|
|
1061
|
+
// instantiation because no live DOM read can recover it (a canvas's
|
|
1062
|
+
// `width`/`height` properties are the bitmap size, but only while the
|
|
1063
|
+
// element still has them). The element's own properties are the fallback
|
|
1064
|
+
// for a producer that omits the annotation.
|
|
1065
|
+
function canvasEntry(id, el) {
|
|
1066
|
+
var entry = canvasNodes.get(id);
|
|
1067
|
+
if (entry) return entry;
|
|
1068
|
+
var size = span ? span.canvases.get(id) : null;
|
|
1069
|
+
var w = size && num(size.w) ? size.w : (num(el && el.width) || 300);
|
|
1070
|
+
var h = size && num(size.h) ? size.h : (num(el && el.height) || 150);
|
|
1071
|
+
entry = { off: null, chain: Promise.resolve(), w: w, h: h, dirty: false, sized: false, sizeCss: '' };
|
|
1072
|
+
canvasNodes.set(id, entry);
|
|
1073
|
+
return entry;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Allocated on the first snapshot, not on the first sight of a canvas: a
|
|
1077
|
+
// page can hold canvases nobody ever draws into, and each offscreen buffer
|
|
1078
|
+
// is w×h×4 bytes in the report page.
|
|
1079
|
+
function offscreenFor(entry) {
|
|
1080
|
+
if (!entry.off) {
|
|
1081
|
+
entry.off = document.createElement('canvas');
|
|
1082
|
+
entry.off.width = entry.w;
|
|
1083
|
+
entry.off.height = entry.h;
|
|
458
1084
|
}
|
|
1085
|
+
return entry.off;
|
|
459
1086
|
}
|
|
460
1087
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
}
|
|
1088
|
+
function applyCanvasSnapshot(e) {
|
|
1089
|
+
var el = resolveNode(e.node);
|
|
1090
|
+
if (!el || String(el.tagName || '').toLowerCase() !== 'canvas'
|
|
1091
|
+
|| typeof e.data_url !== 'string' || !e.data_url) {
|
|
1092
|
+
if (span) span.patchFailures++;
|
|
1093
|
+
return;
|
|
490
1094
|
}
|
|
491
|
-
var
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
1095
|
+
var entry = canvasEntry(e.node, el);
|
|
1096
|
+
var region = e.region && num(e.region.x) != null && num(e.region.y) != null ? e.region : null;
|
|
1097
|
+
// Decoding STARTS now, in parallel; the DRAW is what the per-canvas chain
|
|
1098
|
+
// serialises. Region patches therefore composite in EVENT order however
|
|
1099
|
+
// their images finish decoding — the fork's bare `img.onload` handlers
|
|
1100
|
+
// have no such guarantee and would apply two patches in decode order.
|
|
1101
|
+
var img = document.createElement('img');
|
|
1102
|
+
img.src = e.data_url;
|
|
1103
|
+
var decoded = img.decode
|
|
1104
|
+
? img.decode()
|
|
1105
|
+
: new Promise(function (res, rej) { img.onload = res; img.onerror = rej; });
|
|
1106
|
+
entry.chain = entry.chain
|
|
1107
|
+
.then(function () { return decoded; })
|
|
1108
|
+
.then(function () {
|
|
1109
|
+
var c2 = offscreenFor(entry).getContext('2d');
|
|
1110
|
+
if (!c2) return; // a realm with no painting at all (the node suite)
|
|
1111
|
+
if (region) {
|
|
1112
|
+
c2.drawImage(img, region.x, region.y);
|
|
1113
|
+
} else {
|
|
1114
|
+
// No region = full baseline: clear, then draw at (0,0). A region
|
|
1115
|
+
// patch must preserve the surrounding pixels; a baseline must not.
|
|
1116
|
+
c2.clearRect(0, 0, entry.off.width, entry.off.height);
|
|
1117
|
+
c2.drawImage(img, 0, 0);
|
|
1118
|
+
}
|
|
1119
|
+
}, function () {
|
|
1120
|
+
// A payload that will not decode is a recorded change that cannot be
|
|
1121
|
+
// reapplied. Counted, and the chain stays RESOLVED so the snapshots
|
|
1122
|
+
// after it still land.
|
|
1123
|
+
if (span) span.patchFailures++;
|
|
1124
|
+
})
|
|
1125
|
+
// …and the same for a throw inside the DRAW, which the handler above
|
|
1126
|
+
// cannot see: it is the previous link's rejection handler, not this
|
|
1127
|
+
// one's. Both links end resolved or the canvas stops compositing.
|
|
1128
|
+
.catch(chainFailed);
|
|
1129
|
+
entry.dirty = true;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Design §3.3, measured tri-engine by Task 0: a canvas in this sandbox has
|
|
1133
|
+
// NO intrinsic size, so one with no CSS size collapses and takes the
|
|
1134
|
+
// surrounding layout with it, while `width:50%` measures 150 in a 300px box
|
|
1135
|
+
// and `width:50%` with auto height honours the intrinsic ratio. The repair
|
|
1136
|
+
// therefore fires only where the box collapsed and can never override
|
|
1137
|
+
// responsive CSS — unlike the fork's unconditional pin, which turns a
|
|
1138
|
+
// percentage width into a fixed pixel width and then makes CH's own
|
|
1139
|
+
// alignment check report a divergence the viewer caused.
|
|
1140
|
+
//
|
|
1141
|
+
// MEASURED ON THE CONTENT BOX, not on `getBoundingClientRect()`, and that
|
|
1142
|
+
// is a correction to §3.3 rather than a detail. `jspsych-full` segment 9's
|
|
1143
|
+
// sketchpad canvas carries a 2px border, so the collapse reads as a 4×28
|
|
1144
|
+
// BORDER box — non-zero, so a rect-based guard passes it by and the
|
|
1145
|
+
// composite is presented into a 4-pixel-wide element. `clientWidth` /
|
|
1146
|
+
// `clientHeight` are the padding box, which is both where the collapse
|
|
1147
|
+
// lands and what `background-origin: padding-box` sizes the presentation
|
|
1148
|
+
// against. Task 0's probe could not see this: its canvases had no border.
|
|
1149
|
+
function canvasSizeCss(el, entry) {
|
|
1150
|
+
if (entry.sized) return entry.sizeCss;
|
|
1151
|
+
var w = el.clientWidth;
|
|
1152
|
+
var h = el.clientHeight;
|
|
1153
|
+
if (typeof w !== 'number' || typeof h !== 'number') {
|
|
1154
|
+
var r = null;
|
|
1155
|
+
try { r = el.getBoundingClientRect(); } catch (err) { return ''; }
|
|
1156
|
+
if (!r) return '';
|
|
1157
|
+
w = r.width; h = r.height;
|
|
1158
|
+
}
|
|
1159
|
+
entry.sized = true;
|
|
1160
|
+
entry.sizeCss = (w === 0 || h === 0)
|
|
1161
|
+
? 'display:inline-block;width:' + entry.w + 'px;height:' + entry.h + 'px;'
|
|
1162
|
+
: '';
|
|
1163
|
+
return entry.sizeCss;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function writeCanvasRule(doc, id, entry, css) {
|
|
1167
|
+
var style = doc.head.querySelector('[' + CANVAS_RULE_ATTR + '="' + id + '"]');
|
|
1168
|
+
if (!css) {
|
|
1169
|
+
if (style && style.parentNode) style.parentNode.removeChild(style);
|
|
1170
|
+
return;
|
|
495
1171
|
}
|
|
496
|
-
if (
|
|
497
|
-
|
|
498
|
-
|
|
1172
|
+
if (!style) {
|
|
1173
|
+
style = doc.createElement('style');
|
|
1174
|
+
style.setAttribute(CANVAS_RULE_ATTR, String(id));
|
|
1175
|
+
// Appended AFTER the shell rules, which are themselves after the
|
|
1176
|
+
// recording's sheets, so nothing the recording carries outranks it.
|
|
1177
|
+
doc.head.appendChild(style);
|
|
499
1178
|
}
|
|
500
|
-
|
|
1179
|
+
style.textContent = '[' + CANVAS_ATTR + '="' + id + '"]{' + css + '}';
|
|
501
1180
|
}
|
|
502
1181
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
if (!
|
|
506
|
-
|
|
507
|
-
var
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
|
1182
|
+
function presentCanvas(doc, id, entry) {
|
|
1183
|
+
var el = resolveNode(id);
|
|
1184
|
+
if (!el || !el.setAttribute) { dropCanvas(doc, id); return; }
|
|
1185
|
+
el.setAttribute(CANVAS_ATTR, String(id));
|
|
1186
|
+
var css = canvasSizeCss(el, entry);
|
|
1187
|
+
var url = entry.off && entry.off.toDataURL ? String(entry.off.toDataURL('image/png')) : '';
|
|
1188
|
+
if (DATA_URL_RE.test(url)) {
|
|
1189
|
+
// `!important` on the background only: the composite IS what was on
|
|
1190
|
+
// screen, so the recording's own background must not outrank it. The
|
|
1191
|
+
// SIZE carries none, because §3.3 leaves page CSS authoritative.
|
|
1192
|
+
css += 'background-image:url("' + url + '") !important;' +
|
|
1193
|
+
'background-size:100% 100% !important;background-repeat:no-repeat !important';
|
|
1194
|
+
}
|
|
1195
|
+
writeCanvasRule(doc, id, entry, css);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function dropCanvas(doc, id) {
|
|
1199
|
+
canvasNodes.delete(id);
|
|
1200
|
+
var style = doc && doc.head.querySelector('[' + CANVAS_RULE_ATTR + '="' + id + '"]');
|
|
1201
|
+
if (style && style.parentNode) style.parentNode.removeChild(style);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// The once-per-applied-batch boundary (design §3.1). Only canvases a
|
|
1205
|
+
// snapshot touched in THIS batch are re-encoded; the rest keep the rule
|
|
1206
|
+
// they already have, which is the cheap follow-on Task 0's measurement
|
|
1207
|
+
// pointed at.
|
|
1208
|
+
function presentCanvases() {
|
|
1209
|
+
var doc = frameDoc();
|
|
1210
|
+
if (!doc) return;
|
|
1211
|
+
// §3.3's repair is owed to every canvas in the span, not only to the ones
|
|
1212
|
+
// that were drawn into: a collapsed canvas nobody snapshots still takes
|
|
1213
|
+
// the layout around it down. Measured once per canvas per mount.
|
|
1214
|
+
if (span) {
|
|
1215
|
+
span.canvases.forEach(function (size, id) {
|
|
1216
|
+
var el = resolveNode(id);
|
|
1217
|
+
if (!el || !el.getBoundingClientRect) return;
|
|
1218
|
+
var entry = canvasEntry(id, el);
|
|
1219
|
+
if (entry.sized || entry.dirty) return; // a dirty one presents below
|
|
1220
|
+
if (canvasSizeCss(el, entry)) {
|
|
1221
|
+
el.setAttribute(CANVAS_ATTR, String(id));
|
|
1222
|
+
writeCanvasRule(doc, id, entry, entry.sizeCss);
|
|
1223
|
+
}
|
|
555
1224
|
});
|
|
556
1225
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
1226
|
+
var ids = [];
|
|
1227
|
+
var gone = [];
|
|
1228
|
+
canvasNodes.forEach(function (entry, id) {
|
|
1229
|
+
// A canvas the span no longer holds (`dom.remove` purged it, id map and
|
|
1230
|
+
// all) must lose its rule with it, or the head keeps presenting pixels
|
|
1231
|
+
// for a node nothing can resolve — and a later `dom.add` re-binding the
|
|
1232
|
+
// id would inherit them.
|
|
1233
|
+
if (!resolveNode(id)) gone.push(id);
|
|
1234
|
+
else if (entry.dirty) ids.push(id);
|
|
1235
|
+
});
|
|
1236
|
+
for (var g = 0; g < gone.length; g++) dropCanvas(doc, gone[g]);
|
|
1237
|
+
for (var i = 0; i < ids.length; i++) {
|
|
1238
|
+
var entry = canvasNodes.get(ids[i]);
|
|
1239
|
+
entry.dirty = false;
|
|
1240
|
+
// A rejected chain is PERMANENT: one throw here (a torn-down head, a
|
|
1241
|
+
// `setAttribute` on a node that just left the document) would silently
|
|
1242
|
+
// skip every later composite for this canvas AND reject
|
|
1243
|
+
// `canvasSettled()` — the one call Task 7's executor is told to await.
|
|
1244
|
+
// The composite link had a rejection handler and this one did not; note
|
|
1245
|
+
// that a trailing `catch` is what actually closes it, since a `then`'s
|
|
1246
|
+
// second argument sees the PREVIOUS link's rejection and not a throw
|
|
1247
|
+
// inside its own callback. No reachable throw is known.
|
|
1248
|
+
entry.chain = entry.chain.then(present(doc, ids[i], entry)).catch(chainFailed);
|
|
560
1249
|
}
|
|
561
|
-
|
|
562
|
-
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Absorbs anything either link throws, counts it where the analyst can see
|
|
1253
|
+
// it, and leaves the chain RESOLVED so the snapshots after it still land.
|
|
1254
|
+
function chainFailed() {
|
|
1255
|
+
if (span) span.patchFailures++;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// A named factory rather than a closure inside the loop: `var` has no block
|
|
1259
|
+
// scope, and capturing the loop variable directly is the classic way to
|
|
1260
|
+
// present every canvas as the last one.
|
|
1261
|
+
function present(doc, id, entry) {
|
|
1262
|
+
return function () { presentCanvas(doc, id, entry); };
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// What a caller awaits when it must observe a SETTLED canvas — the
|
|
1266
|
+
// checkpoint executor (Task 7) and the visual batteries. Everything else
|
|
1267
|
+
// about a restore is synchronous; this is the one part that is not, because
|
|
1268
|
+
// image decoding is.
|
|
1269
|
+
function canvasSettled() {
|
|
1270
|
+
presentCanvases();
|
|
1271
|
+
var chains = [];
|
|
1272
|
+
canvasNodes.forEach(function (entry) { chains.push(entry.chain); });
|
|
1273
|
+
return Promise.all(chains).then(function () { return true; });
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// ── Media: state, never playback (design §7) ──
|
|
1277
|
+
// The forensic posture, and the fork's. `media_src` is honoured at
|
|
1278
|
+
// instantiation so the element has its shape, `autoplay` is stripped there,
|
|
1279
|
+
// and nothing here ever calls play() or writes currentTime: a replay that
|
|
1280
|
+
// started making noise on the analyst's machine would be a different
|
|
1281
|
+
// product, and a seeked <video> would claim frame-accuracy the format does
|
|
1282
|
+
// not carry.
|
|
1283
|
+
function applyMedia(e) {
|
|
1284
|
+
var target = resolveNode(e.node);
|
|
1285
|
+
if (!target) { if (span) span.patchFailures++; return; }
|
|
1286
|
+
var st = mediaState.get(e.node) ||
|
|
1287
|
+
{ tag: String(target.tagName || 'media').toLowerCase(), state: 'paused', time: 0 };
|
|
1288
|
+
if (e.type === 'media.play') st.state = 'playing';
|
|
1289
|
+
else if (e.type === 'media.pause') st.state = 'paused';
|
|
1290
|
+
else if (e.type === 'media.ended') st.state = 'ended';
|
|
1291
|
+
if (num(e.current_time) != null) st.time = e.current_time;
|
|
1292
|
+
mediaState.set(e.node, st);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function drawMediaBadges() {
|
|
1296
|
+
mediaLayer.textContent = '';
|
|
1297
|
+
mediaState.forEach(function (st) {
|
|
1298
|
+
var glyph = st.state === 'playing' ? '▶' : (st.state === 'ended' ? '■' : '❚❚');
|
|
1299
|
+
mediaLayer.appendChild(el('span', 'replay-media-badge',
|
|
1300
|
+
glyph + ' ' + st.tag + ' ' + st.state + ' ' + (Math.round(st.time * 100) / 100) + 's'));
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// ── §5.8: unknown types ──
|
|
1305
|
+
// SCANNED ONCE AT INIT, for the same reason `findCaptureStop` is: the chip
|
|
1306
|
+
// says "in this recording", which is a claim about the FILE, and a claim
|
|
1307
|
+
// about the file cannot be assembled from wherever the playhead has been.
|
|
1308
|
+
// Computed during the walk it stayed empty until the analyst happened to
|
|
1309
|
+
// scrub into the segment carrying the unknown type — the "never fail
|
|
1310
|
+
// silently" rule failing silently.
|
|
1311
|
+
//
|
|
1312
|
+
// The scan also makes the OCCURRENCE count restore-stable for free: it
|
|
1313
|
+
// counts what the file holds, not how many times the walk replayed it,
|
|
1314
|
+
// which is what made a per-occurrence count untenable when this was lazy.
|
|
1315
|
+
function noteUnknownType(type, count) {
|
|
1316
|
+
if (unknownTypes.has(type)) return;
|
|
1317
|
+
unknownTypes.set(type, count || 1);
|
|
1318
|
+
if (typeof console !== 'undefined' && console && console.warn) {
|
|
1319
|
+
console.warn('[cyborg-hunter-replay] event type "' + type +
|
|
1320
|
+
'" is not in this viewer\'s vocabulary; skipping it (spec §5.8)');
|
|
1321
|
+
}
|
|
1322
|
+
var names = [];
|
|
1323
|
+
var events = 0;
|
|
1324
|
+
unknownTypes.forEach(function (n, k) { names.push(k); events += n; });
|
|
1325
|
+
unknownChip.textContent = names.length + ' event type(s) in this recording are not ' +
|
|
1326
|
+
'recognised by this viewer and were skipped, ' + events + ' event(s) in total: ' +
|
|
1327
|
+
names.join(', ') + '.';
|
|
1328
|
+
unknownChip.style.display = '';
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function scanUnknownTypes() {
|
|
1332
|
+
var counts = new Map();
|
|
1333
|
+
for (var si = 0; si < segments.length; si++) {
|
|
1334
|
+
var evs = segments[si].events || [];
|
|
1335
|
+
for (var ei = 0; ei < evs.length; ei++) {
|
|
1336
|
+
var t = evs[ei] && evs[ei].type;
|
|
1337
|
+
if (typeof t !== 'string' || KNOWN_TYPES[t] === true) continue;
|
|
1338
|
+
counts.set(t, (counts.get(t) || 0) + 1);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
// Sorted so the chip reads the same way twice over one file.
|
|
1342
|
+
var types = [];
|
|
1343
|
+
counts.forEach(function (_n, k) { types.push(k); });
|
|
1344
|
+
types.sort();
|
|
1345
|
+
for (var i = 0; i < types.length; i++) noteUnknownType(types[i], counts.get(types[i]));
|
|
1346
|
+
}
|
|
1347
|
+
scanUnknownTypes();
|
|
1348
|
+
|
|
1349
|
+
// ── Event application ──
|
|
1350
|
+
function resolveNode(id) {
|
|
1351
|
+
return span && num(id) != null ? span.idMap.get(id) : undefined;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// Form state lives in element PROPERTIES, which no DOM snapshot carries, so
|
|
1355
|
+
// without this every replayed form would look untouched. The seed and the
|
|
1356
|
+
// recorded stream reach it through THIS function — design §6's whole point.
|
|
1357
|
+
function applyInput(e) {
|
|
1358
|
+
var target = resolveNode(e.node);
|
|
1359
|
+
if (!target) { if (span) span.patchFailures++; return; }
|
|
1360
|
+
if (e.type === 'input.checked') {
|
|
1361
|
+
target.checked = !!e.checked;
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
if (e.type === 'input.select') {
|
|
1365
|
+
var values = e.values || [];
|
|
1366
|
+
var opts = target.options || [];
|
|
1367
|
+
for (var i = 0; i < opts.length; i++) {
|
|
1368
|
+
opts[i].selected = values.indexOf(opts[i].value) !== -1;
|
|
1369
|
+
}
|
|
1370
|
+
return;
|
|
563
1371
|
}
|
|
564
|
-
if (!target) return;
|
|
565
1372
|
if (e.redacted) {
|
|
566
|
-
|
|
1373
|
+
// The identity is withheld by design (spec §8); the LENGTH is not, and
|
|
1374
|
+
// showing it as bullets is the honest reconstruction.
|
|
1375
|
+
target.value = new Array((num(e.value_len) || 0) + 1).join('•');
|
|
567
1376
|
} else if (e.value != null) {
|
|
568
1377
|
target.value = e.value;
|
|
569
1378
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
var target =
|
|
575
|
-
if (!target) {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function applyElementScroll(e) {
|
|
1382
|
+
if (e.redacted) return; // unresolvable BY DESIGN
|
|
1383
|
+
var target = resolveNode(e.node);
|
|
1384
|
+
if (!target) { if (span) span.patchFailures++; return; }
|
|
1385
|
+
try { target.scrollTop = num(e.y) || 0; target.scrollLeft = num(e.x) || 0; }
|
|
1386
|
+
catch (err) { if (span) span.patchFailures++; }
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// One dispatch for recorded events AND for the synthetic t=0 events that
|
|
1390
|
+
// seed `initial_state` (design §6). It implements spec §5 IN FULL rather
|
|
1391
|
+
// than CH's capture subset (design §7): the conformance moment is this
|
|
1392
|
+
// viewer playing a foreign conforming file, and a viewer that renders only
|
|
1393
|
+
// what CH captures cannot play one.
|
|
1394
|
+
//
|
|
1395
|
+
// Types with no state to apply — `mouse.*`/`touch.*`/`key.*`,
|
|
1396
|
+
// `clipboard.*`, `focus`/`blur`, `visibility.*`, `fullscreen.*` — are
|
|
1397
|
+
// rendered by the overlay, the keycast, the lane and the ticker, which read
|
|
1398
|
+
// the event stream directly. They are listed in KNOWN_TYPES so §5.8's
|
|
1399
|
+
// counter can tell "handled elsewhere" from "not understood".
|
|
1400
|
+
function applyEvent(e) {
|
|
1401
|
+
if (!e || typeof e.type !== 'string') return;
|
|
1402
|
+
if (e.camera) foldEventCamera(e.camera);
|
|
1403
|
+
// `applyPatch` returns false for anything that is not one of §5.1's four
|
|
1404
|
+
// verbs, so the vocabulary dispatch below needs no list of its own.
|
|
1405
|
+
if (span && applyPatch(e, span)) return;
|
|
1406
|
+
var type = e.type;
|
|
1407
|
+
if (type === 'input.value' || type === 'input.checked' || type === 'input.select') {
|
|
1408
|
+
if (span) applyInput(e);
|
|
1409
|
+
// Spec §5.2 puts these three in the same non-move input union as
|
|
1410
|
+
// mouse/key/touch, so a producer MAY carry §6 blocks on them. CH's own
|
|
1411
|
+
// `withAlignment` does not (capture-trace.js:505-551 covers
|
|
1412
|
+
// mouse.down/up/click, non-repeat key.down and touch.start/end), but
|
|
1413
|
+
// the conformance moment is this viewer playing a FOREIGN file
|
|
1414
|
+
// (design §7): geometry the viewer receives and ignores is geometry it
|
|
1415
|
+
// declined to check while the chip says "verified". (T5.6 fix)
|
|
1416
|
+
//
|
|
1417
|
+
// Offered only when the blocks are THERE, unlike the discrete branch
|
|
1418
|
+
// below. A redacted `input.value` carries `{node, redacted, value_len}`
|
|
1419
|
+
// and no alignment fields (§5.2): that is a VALUE withholding, and
|
|
1420
|
+
// bucketing it as `redacted` would count it in a chip about geometry
|
|
1421
|
+
// — `key.*`'s redacted variant is the one that withholds a target.
|
|
1422
|
+
if (e.camera || e.anchor) offerToCheck(e);
|
|
1423
|
+
} else if (type === 'scroll.window') {
|
|
1424
|
+
cam.x = num(e.x) || 0; cam.y = num(e.y) || 0;
|
|
1425
|
+
applyCamScroll();
|
|
1426
|
+
} else if (type === 'scroll.element') {
|
|
1427
|
+
if (span) applyElementScroll(e);
|
|
1428
|
+
} else if (type === 'canvas.snapshot') {
|
|
1429
|
+
if (span) applyCanvasSnapshot(e);
|
|
1430
|
+
} else if (type.indexOf('media.') === 0 && KNOWN_TYPES[type] === true) {
|
|
1431
|
+
if (span) applyMedia(e);
|
|
1432
|
+
} else if (KNOWN_TYPES[type] !== true) {
|
|
1433
|
+
noteUnknownType(type);
|
|
1434
|
+
} else if (DISCRETE_TYPES[type] === true) {
|
|
1435
|
+
offerToCheck(e);
|
|
588
1436
|
}
|
|
589
1437
|
}
|
|
590
1438
|
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
1439
|
+
// Every non-move input event is OFFERED to the check; the §6 MAY-omit rule
|
|
1440
|
+
// is decided inside `evaluateCheck`, in one place, because "no camera block
|
|
1441
|
+
// means no check" is a statement about the check and not about the
|
|
1442
|
+
// dispatch. The camera size is flushed first: the check reads the frame's
|
|
1443
|
+
// layout box, and a pending resize would have it measure the previous one.
|
|
1444
|
+
function offerToCheck(e) {
|
|
1445
|
+
if (e.__chk != null) return;
|
|
1446
|
+
if (pendingCamSize) { flushCamSize(); applyCamScroll(); }
|
|
1447
|
+
evaluateCheck(e);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function applyEntry(w) {
|
|
1451
|
+
if (w.stream === 'stylesheet') {
|
|
1452
|
+
var doc = frameDoc();
|
|
1453
|
+
if (doc) applySheetEvent(doc, w.payload);
|
|
1454
|
+
} else if (w.stream === 'viewport') {
|
|
1455
|
+
foldViewport(w.payload);
|
|
1456
|
+
} else {
|
|
1457
|
+
applyEvent(w.payload);
|
|
598
1458
|
}
|
|
599
|
-
|
|
600
|
-
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// ── initial_state seeding (design §6) ──
|
|
1462
|
+
// Synthetic events at t=0 through the same handlers, so "scrub to 0
|
|
1463
|
+
// restores the seed" and "play from 0 reaches the seed" cannot drift.
|
|
1464
|
+
// Direct-DOM writes were rejected: they would need their own redaction
|
|
1465
|
+
// handling, their own <select multiple> handling and their own element
|
|
1466
|
+
// dispatch — a second implementation of the input cases.
|
|
1467
|
+
function seedInitialState(start) {
|
|
1468
|
+
var st = start >= 0 ? segments[start].initialState : null;
|
|
1469
|
+
// With NO initial_state, window scroll still resets to (0,0): the frame
|
|
1470
|
+
// survives segment changes, so a segment that scrolled would otherwise
|
|
1471
|
+
// leak its offset into the next one. Element scroll and form state need
|
|
1472
|
+
// no reset — their nodes were just rebuilt.
|
|
1473
|
+
var scroll = st && st.scroll ? st.scroll : { x: 0, y: 0 };
|
|
1474
|
+
applyEvent({ type: 'scroll.window', t: 0, x: num(scroll.x) || 0, y: num(scroll.y) || 0 });
|
|
1475
|
+
if (!st) return;
|
|
1476
|
+
(st.element_scroll || []).forEach(function (s) {
|
|
1477
|
+
applyEvent({ type: 'scroll.element', t: 0, node: s.node, x: s.x, y: s.y });
|
|
1478
|
+
});
|
|
1479
|
+
(st.form || []).forEach(function (f) {
|
|
1480
|
+
if (f.value !== undefined) applyEvent({ type: 'input.value', t: 0, node: f.node, value: f.value });
|
|
1481
|
+
else if (f.checked !== undefined) applyEvent({ type: 'input.checked', t: 0, node: f.node, checked: f.checked });
|
|
1482
|
+
else if (f.selected !== undefined) applyEvent({ type: 'input.select', t: 0, node: f.node, values: f.selected });
|
|
1483
|
+
});
|
|
1484
|
+
// Media is rendered as badges and lane markers, never played (design §7),
|
|
1485
|
+
// so the seed's playback positions ride the same synthetic-event path as
|
|
1486
|
+
// the recorded ones and reach the same badge.
|
|
1487
|
+
(st.media || []).forEach(function (m) {
|
|
1488
|
+
applyEvent({ type: 'media.time', t: 0, node: m.node, current_time: m.current_time });
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// ── Restore (design §5) ──
|
|
1493
|
+
function restore(targetSeg, targetT) {
|
|
1494
|
+
var doc = frameDoc();
|
|
1495
|
+
segIdx = targetSeg;
|
|
1496
|
+
var s = segments[targetSeg];
|
|
1497
|
+
// The participant's own view state is a function of the playhead, like
|
|
1498
|
+
// the canvas composites and the check memo below, so it is rebuilt by the
|
|
1499
|
+
// walk rather than carried across a restore.
|
|
1500
|
+
camView = null;
|
|
1501
|
+
|
|
1502
|
+
// A continuation with `dom.*` events and no keyframe before it is a §3
|
|
1503
|
+
// violation. The model marks it; the viewer refuses to play it with a
|
|
1504
|
+
// visible reason rather than mounting an empty body and letting the
|
|
1505
|
+
// analyst read absence as evidence.
|
|
1506
|
+
if (s.defect) {
|
|
1507
|
+
defectChip.textContent = 'Segment ' + s.index + ' cannot be reconstructed: ' +
|
|
1508
|
+
'it records DOM changes but no keyframe precedes it (' + s.defect + ').';
|
|
1509
|
+
defectChip.style.display = '';
|
|
1510
|
+
span = null; walk = []; appliedIdx = 0; spanStart = -1; spanEnd = -1;
|
|
1511
|
+
resetCanvases(doc);
|
|
1512
|
+
mediaState = new Map();
|
|
1513
|
+
if (doc) { mountTree(null, doc.body, doc); stats.mounts++; }
|
|
1514
|
+
seedCamera(targetSeg);
|
|
1515
|
+
pendingCamSize = true;
|
|
1516
|
+
flushCamSize();
|
|
1517
|
+
// The previous segment's placeholder chips must not survive into a
|
|
1518
|
+
// segment that shows nothing: the latches were cleared by loadSegment,
|
|
1519
|
+
// and this is what puts the chips back in step with them.
|
|
1520
|
+
updatePlaceholderChips();
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
defectChip.style.display = 'none';
|
|
1524
|
+
|
|
1525
|
+
var start = spanStartOf(targetSeg);
|
|
1526
|
+
// Trace-only recordings have no span at all: each segment is walked on
|
|
1527
|
+
// its own so the camera fold still runs (design §10 keeps that path).
|
|
1528
|
+
spanStart = start;
|
|
1529
|
+
spanEnd = start >= 0 ? spanEndOf(start) : targetSeg;
|
|
1530
|
+
var walkStart = start >= 0 ? start : targetSeg;
|
|
1531
|
+
var walkEnd = start >= 0 ? spanEnd : targetSeg;
|
|
1532
|
+
|
|
1533
|
+
// The check memo belongs to the SPAN, not to the current segment.
|
|
1534
|
+
// `evaluateCheck` caches on the event object and the walk replays every
|
|
1535
|
+
// segment from the keyframe forward, so clearing only the selected
|
|
1536
|
+
// segment (which is where `loadSegment` used to do it) left the earlier
|
|
1537
|
+
// segments of the same span holding checks computed against a stage
|
|
1538
|
+
// transform and a reconstruction this restore is about to replace. The
|
|
1539
|
+
// clear goes where the state it describes is rebuilt.
|
|
1540
|
+
for (var c = walkStart; c <= walkEnd; c++) {
|
|
1541
|
+
var evs = segments[c].events;
|
|
1542
|
+
for (var d = 0; d < evs.length; d++) if (evs[d].__chk) delete evs[d].__chk;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// 1. mount the span keyframe, fresh id map
|
|
1546
|
+
if (doc) {
|
|
1547
|
+
span = mountTree(start >= 0 ? segments[start].initialDom : null, doc.body, doc);
|
|
1548
|
+
stats.mounts++;
|
|
1549
|
+
} else {
|
|
1550
|
+
span = null;
|
|
1551
|
+
}
|
|
1552
|
+
// Canvas composites and media state are span state, and both are
|
|
1553
|
+
// functions of the playhead: the walk below re-composites every snapshot
|
|
1554
|
+
// it passes, so carrying either across a restore would show the analyst
|
|
1555
|
+
// pixels from a position they have left. (Design §5 lists caching them
|
|
1556
|
+
// as optimisation (a), NOT taken up front.) The rule elements go with
|
|
1557
|
+
// them, or the shell head accumulates one per restore.
|
|
1558
|
+
resetCanvases(doc);
|
|
1559
|
+
mediaState = new Map();
|
|
1560
|
+
// 2. derive stylesheet state at the keyframe origin
|
|
1561
|
+
if (doc) deriveSheets(doc, segments[walkStart].origin);
|
|
1562
|
+
// camera seed comes from the span keyframe: that is the state a restore
|
|
1563
|
+
// actually opens with.
|
|
1564
|
+
seedCamera(walkStart);
|
|
1565
|
+
pendingCamSize = true;
|
|
1566
|
+
flushCamSize();
|
|
1567
|
+
// 3. seed initial_state — AFTER the mount (a form write on a node the
|
|
1568
|
+
// tree has not created yet is a silent no-op) and BEFORE the events.
|
|
1569
|
+
seedInitialState(start);
|
|
1570
|
+
// The placeholder latch is sampled HERE as well as at the end of every
|
|
1571
|
+
// applied batch, because the walk below is one batch: a `dom.remove`
|
|
1572
|
+
// landing at tRel 0 of the target segment is applied inside the very
|
|
1573
|
+
// first `applyUpTo`, so an element that the KEYFRAME had would never be
|
|
1574
|
+
// seen by a latch that only samples afterwards. The chip's contract is
|
|
1575
|
+
// "existed at ANY point in this span" (§13 absence-of-evidence), and that
|
|
1576
|
+
// must not depend on where a removal falls relative to a sample. Found by
|
|
1577
|
+
// the alignment battery on WebKit, whose 1 ms timer granularity makes the
|
|
1578
|
+
// tRel-0 removal reachable from a real recording. (T5.8)
|
|
1579
|
+
updatePlaceholderChips();
|
|
1580
|
+
// 4. walk the span, merged with the session streams at §7 precedence
|
|
1581
|
+
walk = buildWalk(walkStart, walkEnd);
|
|
1582
|
+
appliedIdx = 0;
|
|
1583
|
+
applyUpTo(targetSeg, targetT);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function applyUpTo(targetSeg, targetT) {
|
|
1587
|
+
for (; appliedIdx < walk.length; appliedIdx++) {
|
|
1588
|
+
var w = walk[appliedIdx];
|
|
1589
|
+
if (w.seg > targetSeg || (w.seg === targetSeg && w.t > targetT)) break;
|
|
1590
|
+
applyEntry(w);
|
|
601
1591
|
}
|
|
602
|
-
if (
|
|
603
|
-
|
|
604
|
-
|
|
1592
|
+
if (pendingCamSize) { flushCamSize(); applyCamScroll(); }
|
|
1593
|
+
// The batch boundary design §3.1 defers presentation to — AFTER the
|
|
1594
|
+
// camera flush, because the repair measures a used size and the iframe
|
|
1595
|
+
// was just resized. One PNG re-encode per canvas a snapshot touched,
|
|
1596
|
+
// rather than one per snapshot.
|
|
1597
|
+
presentCanvases();
|
|
1598
|
+
updatePlaceholderChips();
|
|
605
1599
|
}
|
|
606
1600
|
|
|
607
|
-
// ── Alignment self-check (per anchored
|
|
608
|
-
// Compares INDEPENDENT coordinate systems
|
|
609
|
-
// camera assertions — applied scroll/
|
|
610
|
-
// rect — recorded
|
|
611
|
-
// containment —
|
|
1601
|
+
// ── Alignment self-check (per anchored event) ──
|
|
1602
|
+
// Compares INDEPENDENT coordinate systems:
|
|
1603
|
+
// camera assertions — applied scroll/layout width vs §6 camera (±1px)
|
|
1604
|
+
// rect — recorded anchor.rect vs replayed rect (±3px/edge)
|
|
1605
|
+
// containment — event client point inside the replayed rect (2px)
|
|
612
1606
|
// hit-test — elementFromPoint resolves the anchor family
|
|
613
|
-
// stage transform — iframe's REAL on-page offset vs computed
|
|
614
|
-
//
|
|
615
|
-
//
|
|
1607
|
+
// stage transform — the iframe's REAL on-page offset vs the computed
|
|
1608
|
+
// letterbox (±2px); this one tests the viewer
|
|
1609
|
+
// Any failed predicate ⇒ 'uncertain' with reasons, surfaced by chips, lane
|
|
1610
|
+
// and the cursor glyph. Any predicate that could not RUN — the recording
|
|
1611
|
+
// did not state the field, the event has no point, the box could not be
|
|
1612
|
+
// measured — is named in `skipped` (see ALL_PREDICATES) and surfaced in
|
|
1613
|
+
// `getChecks()` alone: a skip is not a failure, but a check with one is not
|
|
1614
|
+
// a full verification either, and both of those have to stay sayable.
|
|
1615
|
+
//
|
|
1616
|
+
// Anchor resolution changed completely: `anchor.node` is an integer id
|
|
1617
|
+
// resolved through the span id map. No markers, no child-index path, no
|
|
1618
|
+
// getElementById guess. Alignment blocks are OPTIONAL (§6 cost rule), and
|
|
1619
|
+
// absence is NOT failure — no camera block means no check.
|
|
1620
|
+
//
|
|
1621
|
+
// THE SEMANTICS ARE PINNED IN `tests/replay/alignment-viewer-model.test.js`
|
|
1622
|
+
// (T5.6): each predicate — and each conjunct inside it — failing on its own
|
|
1623
|
+
// injected corruption and passing otherwise, the three §7 anchor outcomes,
|
|
1624
|
+
// both redaction buckets, and the §6 MAY-omit rule. That file models layout
|
|
1625
|
+
// over the frame realm to do it; REAL geometry (fonts, reflow, zoom, pinch,
|
|
1626
|
+
// transforms) is the Playwright battery's, and neither substitutes for the
|
|
1627
|
+
// other.
|
|
616
1628
|
function evaluateCheck(e) {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
if (t.shadow) {
|
|
629
|
-
reasons.push('shadow content not captured');
|
|
630
|
-
trialHadShadow = true;
|
|
631
|
-
updateIframeWarning(doc);
|
|
1629
|
+
var doc = frameDoc();
|
|
1630
|
+
if (!doc || !span) return;
|
|
1631
|
+
// §8's redacted bucket narrows to events carrying `redacted: true` at the
|
|
1632
|
+
// EVENT level — a redacted key.* has no other fields at all (§5.2), so
|
|
1633
|
+
// there is nothing to verify and nothing to report as misaligned. A
|
|
1634
|
+
// redacted ANCHOR is a different thing and is CHECKED: §8 omits
|
|
1635
|
+
// `anchor.id` but keeps `anchor.node` and `anchor.rect`, which is
|
|
1636
|
+
// strictly more than v1 could verify.
|
|
1637
|
+
if (e.redacted) {
|
|
1638
|
+
e.__chk = { status: 'redacted', reasons: ['redacted event'], skipped: ALL_PREDICATES.slice() };
|
|
1639
|
+
return;
|
|
632
1640
|
}
|
|
1641
|
+
var camera = e.camera;
|
|
1642
|
+
var anchor = e.anchor;
|
|
1643
|
+
// §6 alignment fields are OPTIONAL and MAY be omitted on key.up and on
|
|
1644
|
+
// repeats. Absence is not failure: no blocks means NO CHECK, not an
|
|
1645
|
+
// uncertain one. v1 conflated the two, which is how key.up omissions
|
|
1646
|
+
// read as misalignment.
|
|
1647
|
+
if (!camera && !anchor) return;
|
|
1648
|
+
var reasons = [];
|
|
1649
|
+
var skipped = [];
|
|
633
1650
|
|
|
634
|
-
//
|
|
1651
|
+
// Check 1a's comparand is the RECORDING's scroll, not `cam` — the fold is
|
|
1652
|
+
// the viewer's own reading of it, so comparing the applied scroll against
|
|
1653
|
+
// `cam.x/cam.y` compares a value against itself whenever the fold did not
|
|
1654
|
+
// run. It does not always run: `foldEventCamera` requires a non-null
|
|
1655
|
+
// `scroll_x` before it touches anything, so a block stating only
|
|
1656
|
+
// `scroll_y` was folded nowhere, applied nowhere, and read `ok` over a
|
|
1657
|
+
// frame 300px from where the recording put the page. Design §8 says
|
|
1658
|
+
// "applied scrollX/scrollY vs camera.scroll_x/scroll_y"; this is that.
|
|
1659
|
+
// An axis the recording does not state falls back to the fold rather than
|
|
1660
|
+
// to 0, which would invent a divergence out of an absent field. (T5.6 fix)
|
|
635
1661
|
var view = doc.defaultView;
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
1662
|
+
var recX = camera && num(camera.scroll_x) != null ? camera.scroll_x : null;
|
|
1663
|
+
var recY = camera && num(camera.scroll_y) != null ? camera.scroll_y : null;
|
|
1664
|
+
if (view && (recX != null || recY != null)) {
|
|
1665
|
+
var wantX = recX != null ? recX : cam.x;
|
|
1666
|
+
var wantY = recY != null ? recY : cam.y;
|
|
1667
|
+
if (Math.abs((view.scrollX || 0) - wantX) > TOL_CAMERA ||
|
|
1668
|
+
Math.abs((view.scrollY || 0) - wantY) > TOL_CAMERA) {
|
|
639
1669
|
reasons.push('applied scroll diverges (' +
|
|
640
|
-
(view.scrollX || 0) + ',' + (view.scrollY || 0) + ' vs ' +
|
|
1670
|
+
(view.scrollX || 0) + ',' + (view.scrollY || 0) + ' vs ' + wantX + ',' + wantY + ')');
|
|
641
1671
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
1672
|
+
} else {
|
|
1673
|
+
skipped.push('camera.scroll');
|
|
1674
|
+
}
|
|
1675
|
+
var de = doc.documentElement;
|
|
1676
|
+
if (de && camera && num(camera.client_w) != null) {
|
|
1677
|
+
if (Math.abs(de.clientWidth - camera.client_w) > TOL_CAMERA) {
|
|
1678
|
+
reasons.push('frame layout width ' + de.clientWidth + ' vs camera ' + camera.client_w);
|
|
645
1679
|
}
|
|
1680
|
+
} else {
|
|
1681
|
+
skipped.push('camera.client_w');
|
|
646
1682
|
}
|
|
647
1683
|
|
|
648
|
-
//
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1684
|
+
// §7: null means "no applicable target node" — not a failure, and
|
|
1685
|
+
// distinguishable from an id the span could not hold. It suppresses the
|
|
1686
|
+
// three ANCHOR predicates and NOTHING ELSE: returning here bucketed the
|
|
1687
|
+
// whole event as "no anchor" and carried any camera reason into a bucket
|
|
1688
|
+
// the chip counts as clean, while skipping the stage check entirely. A
|
|
1689
|
+
// divergence the viewer can still see is still a divergence, and
|
|
1690
|
+
// `node: null` is the shape every interaction outside the observed root
|
|
1691
|
+
// carries, so the hole was not a corner. (T5.6)
|
|
1692
|
+
var noAnchor = !!(anchor && anchor.node == null);
|
|
1693
|
+
var target = anchor && !noAnchor ? resolveNode(anchor.node) : null;
|
|
1694
|
+
if (target) {
|
|
1695
|
+
if (target.hasAttribute && target.hasAttribute('data-ch-shadow')) {
|
|
1696
|
+
// The interaction retargeted to a shadow host whose content was not
|
|
1697
|
+
// captured — geometry would "verify" against a hollow box. Refuse.
|
|
1698
|
+
reasons.push('shadow content not captured');
|
|
1699
|
+
}
|
|
660
1700
|
var rr = null;
|
|
661
1701
|
try { rr = target.getBoundingClientRect(); } catch (err) { /* leave null */ }
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
1702
|
+
var rect = anchor.rect;
|
|
1703
|
+
// All FOUR edges or none: three of the deltas are NaN for a partial
|
|
1704
|
+
// rect and `NaN > TOL` is false, so a half-stated rect used to run a
|
|
1705
|
+
// comparison that could decide nothing and still counted as verified.
|
|
1706
|
+
var full = !!(rect && num(rect.x) != null && num(rect.y) != null &&
|
|
1707
|
+
num(rect.w) != null && num(rect.h) != null);
|
|
1708
|
+
if (rr && full) {
|
|
1709
|
+
var dL = Math.abs(rr.left - rect.x);
|
|
1710
|
+
var dT = Math.abs(rr.top - rect.y);
|
|
1711
|
+
var dR = Math.abs((rr.left + rr.width) - (rect.x + rect.w));
|
|
1712
|
+
var dB = Math.abs((rr.top + rr.height) - (rect.y + rect.h));
|
|
668
1713
|
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)');
|
|
1714
|
+
reasons.push('target rect moved (Δ ' + Math.round(Math.max(dL, dT, dR, dB)) + 'px)');
|
|
671
1715
|
}
|
|
1716
|
+
} else {
|
|
1717
|
+
skipped.push('rect');
|
|
672
1718
|
}
|
|
673
|
-
|
|
674
|
-
var
|
|
675
|
-
if (
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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()));
|
|
1719
|
+
var px = eventX(e), py = eventY(e);
|
|
1720
|
+
var havePoint = px != null && py != null;
|
|
1721
|
+
if (rr && havePoint) {
|
|
1722
|
+
if (!(px >= rr.left - TOL_CONTAIN && px <= rr.left + rr.width + TOL_CONTAIN &&
|
|
1723
|
+
py >= rr.top - TOL_CONTAIN && py <= rr.top + rr.height + TOL_CONTAIN)) {
|
|
1724
|
+
reasons.push('cursor outside target');
|
|
689
1725
|
}
|
|
1726
|
+
} else {
|
|
1727
|
+
skipped.push('containment');
|
|
1728
|
+
}
|
|
1729
|
+
var under;
|
|
1730
|
+
if (doc.elementFromPoint && havePoint) {
|
|
1731
|
+
try { under = doc.elementFromPoint(px, py); } catch (err) { under = undefined; }
|
|
690
1732
|
}
|
|
1733
|
+
// A point that resolves to NOTHING (outside the frame's own viewport)
|
|
1734
|
+
// is not evidence either way, so it is a skip rather than silence.
|
|
1735
|
+
if (under == null) {
|
|
1736
|
+
skipped.push('hit-test');
|
|
1737
|
+
} else if (under !== target &&
|
|
1738
|
+
!(target.contains && target.contains(under)) &&
|
|
1739
|
+
!(under.contains && under.contains(target))) {
|
|
1740
|
+
reasons.push('hit-test resolves ' +
|
|
1741
|
+
(under.id ? under.tagName.toLowerCase() + '#' + under.id : under.tagName.toLowerCase()));
|
|
1742
|
+
}
|
|
1743
|
+
} else {
|
|
1744
|
+
if (anchor && !noAnchor) {
|
|
1745
|
+
reasons.push('anchor node ' + anchor.node + ' not held by this span (' +
|
|
1746
|
+
(anchor.tag || '?') + (anchor.id ? '#' + anchor.id : '') + ')');
|
|
1747
|
+
}
|
|
1748
|
+
skipped.push('rect', 'containment', 'hit-test');
|
|
691
1749
|
}
|
|
692
1750
|
|
|
693
|
-
// stage transform: the iframe's REAL rendered offset must match the
|
|
694
|
-
// computed letterbox (catches viewer CSS/transform regressions).
|
|
695
1751
|
try {
|
|
696
1752
|
var ir = iframe.getBoundingClientRect();
|
|
697
1753
|
var sr = stage.getBoundingClientRect();
|
|
@@ -699,165 +1755,141 @@
|
|
|
699
1755
|
Math.abs(ir.top - sr.top - oy) > TOL_STAGE) {
|
|
700
1756
|
reasons.push('stage transform drift');
|
|
701
1757
|
}
|
|
702
|
-
} catch (err) {
|
|
1758
|
+
} catch (err) {
|
|
1759
|
+
skipped.push('stage'); // measuring failed — not evidence of misalignment
|
|
1760
|
+
}
|
|
703
1761
|
|
|
704
1762
|
e.__chk = reasons.length > 0
|
|
705
|
-
? { status: 'uncertain', reasons: reasons }
|
|
706
|
-
: { status: 'ok', reasons: [] };
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
var
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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(); }
|
|
1763
|
+
? { status: 'uncertain', reasons: reasons, skipped: skipped }
|
|
1764
|
+
: { status: noAnchor ? 'no-anchor' : 'ok', reasons: [], skipped: skipped };
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// Client coordinates are normative in v2 (§7), so there is no re-projection
|
|
1768
|
+
// step: an event's x/y IS the client point. Touch events carry the point
|
|
1769
|
+
// inside `touches[]`.
|
|
1770
|
+
function eventX(e) {
|
|
1771
|
+
if (num(e.x) != null) return e.x;
|
|
1772
|
+
var t = e.touches && e.touches[0];
|
|
1773
|
+
return t && num(t.x) != null ? t.x : null;
|
|
1774
|
+
}
|
|
1775
|
+
function eventY(e) {
|
|
1776
|
+
if (num(e.y) != null) return e.y;
|
|
1777
|
+
var t = e.touches && e.touches[0];
|
|
1778
|
+
return t && num(t.y) != null ? t.y : null;
|
|
774
1779
|
}
|
|
775
1780
|
|
|
1781
|
+
// ── Seeking ──
|
|
776
1782
|
function seek(T, fromScrub) {
|
|
777
|
-
var
|
|
778
|
-
|
|
1783
|
+
var target = Math.max(0, Math.min(T, seg().durMs));
|
|
1784
|
+
var wasBehind = target < playhead;
|
|
1785
|
+
playhead = target;
|
|
779
1786
|
if (!fromScrub) scrub.value = String(playhead);
|
|
780
|
-
if (iframe) {
|
|
781
|
-
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
|
|
785
|
-
|
|
1787
|
+
if (!shellReady && iframe) { redraw(); return; }
|
|
1788
|
+
if (wasBehind) {
|
|
1789
|
+
// Backward: patches are state transitions, not reversible snapshots.
|
|
1790
|
+
// The restore is synchronous now, so this no longer round-trips
|
|
1791
|
+
// through srcdoc + onload the way v1's rebuildFrame did.
|
|
1792
|
+
restore(segIdx, playhead);
|
|
786
1793
|
} else {
|
|
787
|
-
|
|
788
|
-
// the cursor projection tracks scroll/resize state.
|
|
789
|
-
if (wasBehind) { seedCamera(); appliedIdx = 0; pendingCamSize = true; }
|
|
790
|
-
applyDomUpTo(playhead);
|
|
1794
|
+
applyUpTo(segIdx, playhead);
|
|
791
1795
|
}
|
|
1796
|
+
redraw();
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// The scrub's own path: at most one restore per animation frame, targeting
|
|
1800
|
+
// the LATEST requested time (design §5 — a drag across a deep span would
|
|
1801
|
+
// otherwise queue one span replay per input event).
|
|
1802
|
+
function requestSeek(T) {
|
|
1803
|
+
pendingSeek = T;
|
|
1804
|
+
if (frameQueued) return;
|
|
1805
|
+
frameQueued = true;
|
|
1806
|
+
requestAnimationFrame(function () {
|
|
1807
|
+
frameQueued = false;
|
|
1808
|
+
var t = pendingSeek;
|
|
1809
|
+
pendingSeek = null;
|
|
1810
|
+
if (t != null) seek(t, true);
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
function redraw() {
|
|
792
1815
|
drawOverlay();
|
|
793
1816
|
drawKeycast();
|
|
1817
|
+
drawMediaBadges();
|
|
794
1818
|
drawLane();
|
|
795
1819
|
drawTicker();
|
|
796
1820
|
updateStatusChips();
|
|
797
|
-
clock.textContent = fmtClock(playhead) + ' / ' + fmtClock(
|
|
1821
|
+
clock.textContent = fmtClock(playhead) + ' / ' + fmtClock(seg().durMs);
|
|
798
1822
|
scrub.setAttribute('aria-valuetext',
|
|
799
|
-
fmtClock(playhead) + ' of ' + fmtClock(
|
|
1823
|
+
fmtClock(playhead) + ' of ' + fmtClock(seg().durMs));
|
|
800
1824
|
}
|
|
801
1825
|
|
|
802
1826
|
// ── Overlay drawing ──
|
|
803
1827
|
// Everything draws in CLIENT space then letterboxes onto the stage:
|
|
804
|
-
// stagePt = (ox + clientX·k, oy + clientY·k).
|
|
805
|
-
//
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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;
|
|
1828
|
+
// stagePt = (ox + clientX·k, oy + clientY·k). Trail segments break at
|
|
1829
|
+
// camera discontinuities (a viewport change inside this segment) and at
|
|
1830
|
+
// uncertain interactions — a confident-looking trail must never bridge a
|
|
1831
|
+
// state change it cannot vouch for.
|
|
1832
|
+
function projectEvent(e) {
|
|
1833
|
+
var cx = eventX(e), cy = eventY(e);
|
|
1834
|
+
if (cx == null) return null;
|
|
813
1835
|
return { x: ox + cx * k, y: oy + cy * k };
|
|
814
1836
|
}
|
|
815
1837
|
|
|
1838
|
+
// Viewport-change times inside the current segment, in segment-relative ms
|
|
1839
|
+
// — the camera discontinuities the trail must not cross.
|
|
1840
|
+
function segmentCameraBreaks() {
|
|
1841
|
+
var s = seg();
|
|
1842
|
+
var to = segIdx + 1 < segments.length ? segments[segIdx + 1].origin : Infinity;
|
|
1843
|
+
var out = [];
|
|
1844
|
+
for (var i = 0; i < viewportChanges.length; i++) {
|
|
1845
|
+
var t = num(viewportChanges[i].t) || 0;
|
|
1846
|
+
if (t < s.origin) continue;
|
|
1847
|
+
if (t >= to) break;
|
|
1848
|
+
out.push(round1(t - s.origin));
|
|
1849
|
+
}
|
|
1850
|
+
return out;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
816
1853
|
function drawOverlay() {
|
|
817
1854
|
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
|
818
|
-
var events =
|
|
819
|
-
var
|
|
820
|
-
var
|
|
821
|
-
var
|
|
1855
|
+
var events = seg().events;
|
|
1856
|
+
var breaks = segmentCameraBreaks();
|
|
1857
|
+
var breakIdx = 0;
|
|
1858
|
+
var segs = [[]]; // trail polyline segments
|
|
822
1859
|
var lastPos = null;
|
|
823
1860
|
var lastPosUncertain = false;
|
|
824
1861
|
var ripples = [];
|
|
825
1862
|
for (var i = 0; i < events.length; i++) {
|
|
826
1863
|
var e = events[i];
|
|
827
1864
|
if (e.t > playhead) break;
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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);
|
|
1865
|
+
while (breakIdx < breaks.length && breaks[breakIdx] <= e.t) {
|
|
1866
|
+
segs.push([]); breakIdx++;
|
|
1867
|
+
}
|
|
1868
|
+
if (e.type === 'mouse.move' || e.type === 'touch.move') {
|
|
1869
|
+
var p = projectEvent(e);
|
|
1870
|
+
if (!p) continue;
|
|
840
1871
|
p.t = e.t;
|
|
841
|
-
if (e.t >= playhead - 2500)
|
|
1872
|
+
if (e.t >= playhead - 2500) segs[segs.length - 1].push(p);
|
|
842
1873
|
lastPos = p;
|
|
843
|
-
} else if (e.
|
|
844
|
-
var q = projectEvent(e
|
|
1874
|
+
} else if (e.type === 'mouse.click' || e.type === 'mouse.down' || e.type === 'touch.start') {
|
|
1875
|
+
var q = projectEvent(e);
|
|
1876
|
+
if (!q) continue;
|
|
845
1877
|
q.t = e.t;
|
|
846
1878
|
lastPos = q;
|
|
847
|
-
lastPosUncertain = !!(e.__chk && e.__chk.status
|
|
848
|
-
if (lastPosUncertain)
|
|
1879
|
+
lastPosUncertain = !!(e.__chk && e.__chk.status === 'uncertain');
|
|
1880
|
+
if (lastPosUncertain) segs.push([]); // sever the trail
|
|
849
1881
|
if (e.t >= playhead - 600) ripples.push(q);
|
|
850
1882
|
}
|
|
851
1883
|
}
|
|
852
1884
|
// Cursor trail: fading polyline per segment
|
|
853
|
-
|
|
854
|
-
for (var s = 1; s <
|
|
855
|
-
var age = (playhead -
|
|
1885
|
+
segs.forEach(function (line) {
|
|
1886
|
+
for (var s = 1; s < line.length; s++) {
|
|
1887
|
+
var age = (playhead - line[s].t) / 2500;
|
|
856
1888
|
ctx.strokeStyle = 'rgba(211,47,47,' + (0.85 * (1 - age)).toFixed(3) + ')';
|
|
857
1889
|
ctx.lineWidth = 2;
|
|
858
1890
|
ctx.beginPath();
|
|
859
|
-
ctx.moveTo(
|
|
860
|
-
ctx.lineTo(
|
|
1891
|
+
ctx.moveTo(line[s - 1].x, line[s - 1].y);
|
|
1892
|
+
ctx.lineTo(line[s].x, line[s].y);
|
|
861
1893
|
ctx.stroke();
|
|
862
1894
|
}
|
|
863
1895
|
});
|
|
@@ -898,24 +1930,24 @@
|
|
|
898
1930
|
}
|
|
899
1931
|
|
|
900
1932
|
// ── Keycast (bottom-of-stage chips) ──
|
|
901
|
-
// Pairs
|
|
1933
|
+
// Pairs key.down with its key.up to get each key's [down, up] window, then
|
|
902
1934
|
// returns the ones "live" at T: down and not yet released, or released
|
|
903
|
-
// within KEYCAST_FADE_MS. Non-redacted pairs match by
|
|
904
|
-
// overlapping holds, e.g. Shift+A); redacted
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
//
|
|
1935
|
+
// within KEYCAST_FADE_MS. Non-redacted pairs match by `code` (handles
|
|
1936
|
+
// overlapping holds, e.g. Shift+A); the redacted variant carries NO other
|
|
1937
|
+
// fields (§5.2), so those match FIFO against each other — an approximation
|
|
1938
|
+
// that assumes redacted keystrokes do not overlap, true for the
|
|
1939
|
+
// single-field-typing case this exists to cover.
|
|
908
1940
|
function computeKeycastChips(T) {
|
|
909
|
-
var events =
|
|
1941
|
+
var events = seg().events;
|
|
910
1942
|
var openByCode = new Map();
|
|
911
1943
|
var redactedQueue = [];
|
|
912
1944
|
var tokens = [];
|
|
913
1945
|
for (var i = 0; i < events.length; i++) {
|
|
914
1946
|
var e = events[i];
|
|
915
1947
|
if (e.t > T) break;
|
|
916
|
-
if (e.
|
|
1948
|
+
if (e.type !== 'key.down' && e.type !== 'key.up') continue;
|
|
917
1949
|
if (e.redacted) {
|
|
918
|
-
if (e.
|
|
1950
|
+
if (e.type === 'key.down') {
|
|
919
1951
|
var rtok = { redacted: true, downT: e.t, upT: null };
|
|
920
1952
|
redactedQueue.push(rtok);
|
|
921
1953
|
tokens.push(rtok);
|
|
@@ -925,8 +1957,8 @@
|
|
|
925
1957
|
}
|
|
926
1958
|
continue;
|
|
927
1959
|
}
|
|
928
|
-
if (e.
|
|
929
|
-
// Auto-repeat sends
|
|
1960
|
+
if (e.type === 'key.down') {
|
|
1961
|
+
// Auto-repeat sends key.down without an intervening key.up; keep the
|
|
930
1962
|
// original token open rather than starting a new one.
|
|
931
1963
|
if (!openByCode.has(e.code)) {
|
|
932
1964
|
var tok = { key: e.key, downT: e.t, upT: null };
|
|
@@ -957,21 +1989,22 @@
|
|
|
957
1989
|
});
|
|
958
1990
|
}
|
|
959
1991
|
|
|
960
|
-
// ── Marker lane (per
|
|
1992
|
+
// ── Marker lane (per segment; redrawn per seek so check marks appear) ──
|
|
961
1993
|
function drawLane() {
|
|
962
1994
|
var lctx = lane.getContext('2d');
|
|
963
1995
|
lctx.clearRect(0, 0, lane.width, lane.height);
|
|
964
1996
|
lctx.fillStyle = '#f1efe9';
|
|
965
1997
|
lctx.fillRect(0, 0, lane.width, lane.height);
|
|
966
|
-
var
|
|
967
|
-
var
|
|
1998
|
+
var s = seg();
|
|
1999
|
+
var dur = Math.max(s.durMs, 1);
|
|
2000
|
+
var events = s.events;
|
|
968
2001
|
// Away-bands first (blur→focus, hidden→visible), then point markers.
|
|
969
2002
|
var awayStart = null;
|
|
970
2003
|
for (var i = 0; i < events.length; i++) {
|
|
971
2004
|
var e = events[i];
|
|
972
|
-
if (e.
|
|
2005
|
+
if (e.type === 'blur' || e.type === 'visibility.hidden') {
|
|
973
2006
|
if (awayStart === null) awayStart = e.t;
|
|
974
|
-
} else if (e.
|
|
2007
|
+
} else if (e.type === 'focus' || e.type === 'visibility.visible') {
|
|
975
2008
|
if (awayStart !== null) {
|
|
976
2009
|
lctx.fillStyle = 'rgba(245,124,0,0.45)';
|
|
977
2010
|
lctx.fillRect(awayStart / dur * lane.width, 0,
|
|
@@ -980,55 +2013,127 @@
|
|
|
980
2013
|
}
|
|
981
2014
|
}
|
|
982
2015
|
}
|
|
983
|
-
if (awayStart !== null) { // still away at
|
|
2016
|
+
if (awayStart !== null) { // still away at segment end
|
|
984
2017
|
lctx.fillStyle = 'rgba(245,124,0,0.45)';
|
|
985
2018
|
lctx.fillRect(awayStart / dur * lane.width, 0,
|
|
986
2019
|
lane.width - awayStart / dur * lane.width, lane.height);
|
|
987
2020
|
}
|
|
988
2021
|
events.forEach(function (e) {
|
|
989
|
-
var color = MARKER_COLORS[e.
|
|
2022
|
+
var color = MARKER_COLORS[e.type];
|
|
990
2023
|
// Alignment-check marks: uncertain interactions paint amber so the
|
|
991
2024
|
// analyst can SCRUB TO the exact failing moment.
|
|
992
|
-
if (e.__chk && e.__chk.status
|
|
2025
|
+
if (e.__chk && e.__chk.status === 'uncertain') color = UNCERTAIN_COLOR;
|
|
993
2026
|
if (!color) return;
|
|
994
2027
|
lctx.fillStyle = color;
|
|
995
2028
|
lctx.fillRect(e.t / dur * lane.width - 1, 2, 2, lane.height - 4);
|
|
996
2029
|
});
|
|
2030
|
+
// Guard-friction violations are CH events with no standard type (§5.8
|
|
2031
|
+
// forbids vendor events in the stream), so v2 carries them in the
|
|
2032
|
+
// extension with session-absolute times. They are still integrity
|
|
2033
|
+
// evidence and still belong in the lane.
|
|
2034
|
+
(model.guardViolations || []).forEach(function (g) {
|
|
2035
|
+
var t = num(g && g.t);
|
|
2036
|
+
if (t == null) return;
|
|
2037
|
+
var rel = round1(t - s.origin);
|
|
2038
|
+
if (rel < 0 || rel > dur) return;
|
|
2039
|
+
lctx.fillStyle = GUARD_COLOR;
|
|
2040
|
+
lctx.fillRect(rel / dur * lane.width - 1, 0, 2, lane.height);
|
|
2041
|
+
});
|
|
997
2042
|
}
|
|
998
2043
|
|
|
999
2044
|
function checkSummary() {
|
|
1000
|
-
var events =
|
|
1001
|
-
var ok = 0, uncertain = 0, redacted = 0;
|
|
2045
|
+
var events = seg().events;
|
|
2046
|
+
var ok = 0, uncertain = 0, redacted = 0, noAnchor = 0;
|
|
1002
2047
|
for (var i = 0; i < events.length; i++) {
|
|
1003
2048
|
var c = events[i].__chk;
|
|
1004
2049
|
if (!c) continue;
|
|
1005
2050
|
if (c.status === 'ok') ok++;
|
|
1006
2051
|
else if (c.status === 'redacted') redacted++;
|
|
2052
|
+
else if (c.status === 'no-anchor') noAnchor++;
|
|
1007
2053
|
else uncertain++;
|
|
1008
2054
|
}
|
|
1009
|
-
return { ok: ok, uncertain: uncertain, redacted: redacted };
|
|
2055
|
+
return { ok: ok, uncertain: uncertain, redacted: redacted, noAnchor: noAnchor };
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// What the PARTICIPANT was looking at, at the playhead — as distinct from
|
|
2059
|
+
// where the page was, which is the camera. Both advisories read the last
|
|
2060
|
+
// per-event §6 block the walk has passed, falling back to the segment seed
|
|
2061
|
+
// before the first one: an interaction recorded mid-zoom is a MOMENT, and a
|
|
2062
|
+
// seed-scoped reading calls a session that zoomed halfway through by
|
|
2063
|
+
// whichever value happened to be true at the segment origin. (T5.6's M-6:
|
|
2064
|
+
// `dpr`, `vv_scale` and `vv_offset_*` had no reader anywhere; this is it.)
|
|
2065
|
+
function updateViewChips() {
|
|
2066
|
+
var seedDpr = (seg().camera && num(seg().camera.dpr) != null ? seg().camera.dpr : null);
|
|
2067
|
+
if (seedDpr == null) seedDpr = (model.viewport && num(model.viewport.dpr) != null) ? model.viewport.dpr : null;
|
|
2068
|
+
var recDpr = camView && num(camView.dpr) != null ? camView.dpr : seedDpr;
|
|
2069
|
+
var mine = window.devicePixelRatio;
|
|
2070
|
+
// Resolution-conditional CSS cannot be reproduced in an iframe that
|
|
2071
|
+
// inherits the analyst's DPR (declared limitation, loud).
|
|
2072
|
+
if (recDpr && mine && recDpr !== mine) {
|
|
2073
|
+
dprChip.textContent = 'Recorded at DPR ' + recDpr + ', now viewing at DPR ' +
|
|
2074
|
+
mine + '. Resolution-dependent styling may differ.';
|
|
2075
|
+
dprChip.style.display = '';
|
|
2076
|
+
} else {
|
|
2077
|
+
dprChip.style.display = 'none';
|
|
2078
|
+
}
|
|
2079
|
+
// Pinch zoom is not a layout change — client coordinates and
|
|
2080
|
+
// `getBoundingClientRect` are layout-viewport relative in every engine —
|
|
2081
|
+
// so the reconstruction is CORRECT and the five §8 predicates pass. What
|
|
2082
|
+
// it changes is what the participant could actually see, and a
|
|
2083
|
+
// reconstruction that silently replays a pinched moment at 1× is the
|
|
2084
|
+
// "aligned but not what they saw" case this chip exists for.
|
|
2085
|
+
var sc = camView && num(camView.scale) != null ? camView.scale : null;
|
|
2086
|
+
if (sc != null && Math.abs(sc - 1) > 0.01) {
|
|
2087
|
+
zoomChip.textContent = 'The participant was pinch-zoomed to ' + sc + '× here ' +
|
|
2088
|
+
'(visual viewport offset ' + camView.ox + ',' + camView.oy + '). ' +
|
|
2089
|
+
'The reconstruction replays the full layout at 1×.';
|
|
2090
|
+
zoomChip.style.display = '';
|
|
2091
|
+
} else {
|
|
2092
|
+
zoomChip.style.display = 'none';
|
|
2093
|
+
}
|
|
1010
2094
|
}
|
|
1011
2095
|
|
|
1012
2096
|
function updateStatusChips() {
|
|
2097
|
+
updateViewChips();
|
|
1013
2098
|
var s = checkSummary();
|
|
2099
|
+
// Reset the class every time, not only the text: without this the chip
|
|
2100
|
+
// keeps `replay-warn` from whichever segment last held an uncertain
|
|
2101
|
+
// check, so a clean segment renders an empty chip styled as a warning.
|
|
2102
|
+
alignChip.className = 'replay-note';
|
|
2103
|
+
// FOUR buckets, one sentence. `no-anchor` was counted here and said by
|
|
2104
|
+
// nothing until T5.6: a segment whose interactions all landed outside the
|
|
2105
|
+
// observed root read as a segment with no interactions, and a mixed one
|
|
2106
|
+
// read as fully verified. Neither is a failure — which is why they are
|
|
2107
|
+
// not the warning — but both are the silence this chip exists to prevent.
|
|
2108
|
+
var qual = [];
|
|
2109
|
+
if (s.redacted > 0) qual.push(s.redacted + ' redacted');
|
|
2110
|
+
if (s.noAnchor > 0) qual.push(s.noAnchor + ' with no target');
|
|
1014
2111
|
if (model.tier !== 'dom') {
|
|
1015
2112
|
alignChip.textContent = '';
|
|
1016
|
-
} else if (model.legacy) {
|
|
1017
|
-
alignChip.textContent = ''; // the legacy chip already says it all
|
|
1018
2113
|
} else if (s.uncertain > 0) {
|
|
1019
|
-
|
|
2114
|
+
// The qualifier rides the WARNING branch too. It is the branch an
|
|
2115
|
+
// analyst is most likely to be reading, and dropping it there hid three
|
|
2116
|
+
// unverifiable interactions behind one failed one — the same over-claim
|
|
2117
|
+
// as on the verified branch, one `else if` away. (T5.6 fix)
|
|
2118
|
+
alignChip.textContent = '⚠ ' + s.uncertain + ' interaction(s) failed the alignment self-check' +
|
|
2119
|
+
(qual.length ? ' (' + qual.join(', ') + ')' : '');
|
|
1020
2120
|
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
2121
|
} else if (s.ok > 0) {
|
|
1025
2122
|
alignChip.textContent = 'alignment verified (' + s.ok + ' check' + (s.ok === 1 ? '' : 's') +
|
|
1026
|
-
(
|
|
1027
|
-
|
|
2123
|
+
(qual.length ? '; ' + qual.join(', ') : '') + ')';
|
|
2124
|
+
} else if (qual.length) {
|
|
2125
|
+
alignChip.textContent = 'alignment unverified (' + qual.join(', ') + ')';
|
|
1028
2126
|
} else {
|
|
1029
2127
|
alignChip.textContent = '';
|
|
1030
2128
|
}
|
|
1031
|
-
|
|
2129
|
+
// ONE chip, TWO counters, different questions (design §4): `skipped` is
|
|
2130
|
+
// "the file said something no DOM here could hold" (a tag or attribute
|
|
2131
|
+
// name outside the XML Name production, a null child); `patchFailures` is
|
|
2132
|
+
// "a reference the span could not honour" (an id the map does not hold, a
|
|
2133
|
+
// `before` that is not a child, a target of the wrong kind). They stay
|
|
2134
|
+
// separate so a miss is diagnosable and fold here so an analyst reads one
|
|
2135
|
+
// sentence. Read as live properties — the span state keeps counting.
|
|
2136
|
+
var fails = span ? (span.patchFailures + span.skipped) : 0;
|
|
1032
2137
|
if (fails > 0) {
|
|
1033
2138
|
failChip.textContent = '⚠ ' + fails + ' recorded change(s) could not be reapplied. Replay may diverge from the original session.';
|
|
1034
2139
|
failChip.style.display = '';
|
|
@@ -1037,17 +2142,36 @@
|
|
|
1037
2142
|
}
|
|
1038
2143
|
}
|
|
1039
2144
|
|
|
2145
|
+
// §5.3 has TWO conforming producer modes and a redacted variant, and the
|
|
2146
|
+
// viewer renders all three because it must play foreign files: jsPsych
|
|
2147
|
+
// writes `text`/`html` (content), CH writes `len` (length-only, on privacy
|
|
2148
|
+
// grounds), and a target inside a redacted subtree carries neither. The
|
|
2149
|
+
// redacted case shows the FACT and no measurement — a character count the
|
|
2150
|
+
// file withheld must not be reconstructed from anywhere else.
|
|
2151
|
+
function clipboardLabel(e) {
|
|
2152
|
+
if (e.redacted) return ' [redacted]' + (num(e.len) != null ? ', ' + e.len + ' ch' : '');
|
|
2153
|
+
if (typeof e.text === 'string') {
|
|
2154
|
+
return ' "' + e.text.slice(0, 32) + (e.text.length > 32 ? '…' : '') + '" (' + e.text.length + ' ch)';
|
|
2155
|
+
}
|
|
2156
|
+
if (typeof e.html === 'string') return ' [html] (' + e.html.length + ' ch)';
|
|
2157
|
+
if (num(e.len) != null) return ' (' + e.len + ' ch)';
|
|
2158
|
+
return ' (length not recorded)';
|
|
2159
|
+
}
|
|
2160
|
+
|
|
1040
2161
|
function drawTicker() {
|
|
1041
|
-
var events =
|
|
2162
|
+
var events = seg().events;
|
|
1042
2163
|
var recent = [];
|
|
1043
2164
|
for (var i = events.length - 1; i >= 0 && recent.length < 3; i--) {
|
|
1044
2165
|
var e = events[i];
|
|
1045
2166
|
if (e.t > playhead) continue;
|
|
1046
|
-
if (e.
|
|
1047
|
-
var label = e.
|
|
1048
|
-
if (e.
|
|
1049
|
-
if (e.
|
|
1050
|
-
if (e.
|
|
2167
|
+
if (e.type === 'mouse.move' || e.type === 'touch.move') continue;
|
|
2168
|
+
var label = e.type;
|
|
2169
|
+
if (e.type.indexOf('clipboard.') === 0) label += clipboardLabel(e);
|
|
2170
|
+
if (e.type === 'key.down' && e.key) label += ' ' + JSON.stringify(e.key);
|
|
2171
|
+
if (e.type === 'input.value') {
|
|
2172
|
+
label += e.redacted ? ' [redacted, ' + e.value_len + ' ch]'
|
|
2173
|
+
: (' "' + String(e.value == null ? '' : e.value).slice(0, 24) + '"');
|
|
2174
|
+
}
|
|
1051
2175
|
if (e.__chk && e.__chk.status === 'uncertain') label += ' ⚠(' + e.__chk.reasons[0] + ')';
|
|
1052
2176
|
recent.unshift('[' + fmtClock(e.t) + '] ' + label);
|
|
1053
2177
|
}
|
|
@@ -1062,9 +2186,9 @@
|
|
|
1062
2186
|
seek(playhead + (ts - lastFrame) * speed);
|
|
1063
2187
|
}
|
|
1064
2188
|
lastFrame = ts;
|
|
1065
|
-
if (playhead >=
|
|
1066
|
-
if (autoAdvance &&
|
|
1067
|
-
|
|
2189
|
+
if (playhead >= seg().durMs) {
|
|
2190
|
+
if (autoAdvance && segIdx < segments.length - 1) {
|
|
2191
|
+
loadSegment(segIdx + 1); // continue playing into the next segment
|
|
1068
2192
|
requestAnimationFrame(tick);
|
|
1069
2193
|
return;
|
|
1070
2194
|
}
|
|
@@ -1083,7 +2207,7 @@
|
|
|
1083
2207
|
playBtn.setAttribute('aria-label', 'Play');
|
|
1084
2208
|
return;
|
|
1085
2209
|
}
|
|
1086
|
-
if (playhead >=
|
|
2210
|
+
if (playhead >= seg().durMs) seek(0);
|
|
1087
2211
|
playing = true;
|
|
1088
2212
|
playBtn.textContent = '❚❚';
|
|
1089
2213
|
playBtn.setAttribute('aria-label', 'Pause');
|
|
@@ -1091,65 +2215,51 @@
|
|
|
1091
2215
|
requestAnimationFrame(tick);
|
|
1092
2216
|
});
|
|
1093
2217
|
speedSel.addEventListener('change', function () { speed = Number(speedSel.value) || 1; });
|
|
1094
|
-
scrub.addEventListener('input', function () {
|
|
2218
|
+
scrub.addEventListener('input', function () { requestSeek(Number(scrub.value)); });
|
|
1095
2219
|
scrub.addEventListener('keydown', function (ev) {
|
|
1096
|
-
var step =
|
|
2220
|
+
var step = seg().durMs * 0.05;
|
|
1097
2221
|
if (ev.key === 'PageUp') { seek(playhead + step); ev.preventDefault(); }
|
|
1098
2222
|
else if (ev.key === 'PageDown') { seek(playhead - step); ev.preventDefault(); }
|
|
1099
2223
|
else if (ev.key === 'Home') { seek(0); ev.preventDefault(); }
|
|
1100
|
-
else if (ev.key === 'End') { seek(
|
|
2224
|
+
else if (ev.key === 'End') { seek(seg().durMs); ev.preventDefault(); }
|
|
1101
2225
|
});
|
|
1102
2226
|
function updateSessionPos() {
|
|
1103
|
-
sessionPos.textContent = '
|
|
2227
|
+
sessionPos.textContent = 'Segment ' + (segIdx + 1) + ' of ' + segments.length;
|
|
1104
2228
|
}
|
|
1105
2229
|
|
|
1106
|
-
// Loads
|
|
1107
|
-
// — the continuous-playback boundary in tick()
|
|
1108
|
-
// so play keeps running across the cut;
|
|
1109
|
-
// and boot) wraps it with the playing=false reset a user-driven jump
|
|
2230
|
+
// Loads segment i's reconstruction/camera/scrub state WITHOUT touching
|
|
2231
|
+
// playing/playBtn — the continuous-playback boundary in tick() calls this
|
|
2232
|
+
// directly so play keeps running across the cut; selectSegment() (manual
|
|
2233
|
+
// pick and boot) wraps it with the playing=false reset a user-driven jump
|
|
1110
2234
|
// should have.
|
|
1111
|
-
function
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
scrub.max = String(
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
2235
|
+
function loadSegment(i) {
|
|
2236
|
+
segIdx = i;
|
|
2237
|
+
segSel.value = String(i);
|
|
2238
|
+
scrub.max = String(seg().durMs);
|
|
2239
|
+
playhead = 0;
|
|
2240
|
+
segHadIframe = false;
|
|
2241
|
+
segHadShadow = false;
|
|
2242
|
+
// Check caches are cleared by `restore()`, span-wide — see there.
|
|
2243
|
+
seedCamera(seg().spanStart != null ? seg().spanStart : i);
|
|
1118
2244
|
sizeStage();
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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);
|
|
2245
|
+
if (shellReady || !iframe) restore(i, 0);
|
|
2246
|
+
scrub.value = '0';
|
|
2247
|
+
redraw();
|
|
1138
2248
|
updateSessionPos();
|
|
1139
2249
|
}
|
|
1140
|
-
function
|
|
2250
|
+
function selectSegment(i) {
|
|
1141
2251
|
playing = false;
|
|
1142
2252
|
playBtn.textContent = '▶';
|
|
1143
2253
|
playBtn.setAttribute('aria-label', 'Play');
|
|
1144
|
-
|
|
2254
|
+
loadSegment(i);
|
|
1145
2255
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
2256
|
+
segSel.addEventListener('change', function () {
|
|
2257
|
+
selectSegment(Number(segSel.value) || 0);
|
|
1148
2258
|
});
|
|
1149
2259
|
|
|
1150
2260
|
// Analyst-side resizes (report sidebar, browser zoom, window resize)
|
|
1151
2261
|
// re-derive the stage box and transform — the reconstruction must track
|
|
1152
|
-
// its container, not just the recording
|
|
2262
|
+
// its container, not just the recording.
|
|
1153
2263
|
if (typeof ResizeObserver !== 'undefined') {
|
|
1154
2264
|
var ro = new ResizeObserver(function () {
|
|
1155
2265
|
var newW = Math.min(mount.clientWidth || 720, 960);
|
|
@@ -1163,23 +2273,68 @@
|
|
|
1163
2273
|
ro.observe(mount);
|
|
1164
2274
|
}
|
|
1165
2275
|
|
|
1166
|
-
// Test/debug surface (used by the alignment battery
|
|
2276
|
+
// Test/debug surface (used by the alignment battery and, from Task 7, the
|
|
2277
|
+
// checkpoint executor; not a public API).
|
|
1167
2278
|
mount._chReplayDebug = {
|
|
1168
2279
|
seek: function (t) { seek(t); },
|
|
1169
|
-
|
|
2280
|
+
requestSeek: requestSeek,
|
|
2281
|
+
selectSegment: selectSegment,
|
|
2282
|
+
getPlayhead: function () { return playhead; },
|
|
2283
|
+
getSegment: function () { return segIdx; },
|
|
2284
|
+
getSpanStart: function () { return spanStart; },
|
|
2285
|
+
// Spec §4 id → live node, through the span map the patches resolve
|
|
2286
|
+
// against. `exists` is `getNode(id) !== undefined`.
|
|
2287
|
+
getNode: function (id) { return resolveNode(id); },
|
|
1170
2288
|
getChecks: function () {
|
|
1171
|
-
return
|
|
2289
|
+
return seg().events
|
|
1172
2290
|
.filter(function (e) { return e.__chk; })
|
|
1173
2291
|
.map(function (e) {
|
|
1174
|
-
return {
|
|
2292
|
+
return {
|
|
2293
|
+
t: e.t, type: e.type, status: e.__chk.status, reasons: e.__chk.reasons,
|
|
2294
|
+
// Which §8 comparisons could not run. Diagnostic only — see
|
|
2295
|
+
// ALL_PREDICATES. `ok` with an empty array is full verification.
|
|
2296
|
+
skipped: e.__chk.skipped || []
|
|
2297
|
+
};
|
|
1175
2298
|
});
|
|
1176
2299
|
},
|
|
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 () {
|
|
1179
|
-
|
|
2300
|
+
getCamera: function () { return { x: cam.x, y: cam.y, w: cam.w, h: cam.h, cw: cam.cw, ch: cam.ch, k: k, ox: ox, oy: oy }; },
|
|
2301
|
+
getCounters: function () {
|
|
2302
|
+
var unknown = [];
|
|
2303
|
+
unknownTypes.forEach(function (_v, k) { unknown.push(k); });
|
|
2304
|
+
return {
|
|
2305
|
+
patchFailures: span ? span.patchFailures : 0,
|
|
2306
|
+
skipped: span ? span.skipped : 0,
|
|
2307
|
+
// §5.8: one entry per unrecognised TYPE. Not per occurrence — the
|
|
2308
|
+
// walk replays on every restore, so occurrences would count the
|
|
2309
|
+
// analyst's scrubbing rather than the file.
|
|
2310
|
+
unknownTypes: unknown
|
|
2311
|
+
};
|
|
2312
|
+
},
|
|
2313
|
+
// Awaits the per-canvas decode chains, presenting anything still dirty
|
|
2314
|
+
// first. The one asynchronous part of a restore (design §3.1).
|
|
2315
|
+
canvasSettled: canvasSettled,
|
|
2316
|
+
getMediaState: function () {
|
|
2317
|
+
var out = [];
|
|
2318
|
+
mediaState.forEach(function (st, id) { out.push({ node: id, state: st.state, time: st.time }); });
|
|
2319
|
+
return out;
|
|
2320
|
+
},
|
|
2321
|
+
// The merged span list, in application order — the §7 tie precedence is
|
|
2322
|
+
// this array's order, so it is what a test asserts against.
|
|
2323
|
+
getWalk: function () {
|
|
2324
|
+
return walk.map(function (w) {
|
|
2325
|
+
return { seg: w.seg, t: w.t, stream: w.stream, type: w.payload.type };
|
|
2326
|
+
});
|
|
2327
|
+
},
|
|
2328
|
+
getStats: function () { return { shellWrites: stats.shellWrites, mounts: stats.mounts }; },
|
|
2329
|
+
frameReady: function () { return iframe ? shellReady : true; }
|
|
1180
2330
|
};
|
|
1181
2331
|
|
|
1182
2332
|
// ── Boot ──
|
|
1183
|
-
|
|
2333
|
+
// The shell is the only asynchronous step, and only in a real browser
|
|
2334
|
+
// (happy-dom parses srcdoc synchronously). loadSegment sizes the stage and
|
|
2335
|
+
// draws immediately either way; the reconstruction lands when the document
|
|
2336
|
+
// does.
|
|
2337
|
+
if (iframe) writeShell();
|
|
2338
|
+
selectSegment(0);
|
|
1184
2339
|
};
|
|
1185
2340
|
})();
|