cyborg-hunter 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/CITATION.cff +2 -2
  3. package/README.md +21 -6
  4. package/dist/cyborg-hunter-replay.js +3 -3
  5. package/dist/cyborg-hunter.esm.js +3 -2
  6. package/dist/cyborg-hunter.min.js +3 -3
  7. package/dist/extension-cyborg-hunter.js +1 -1
  8. package/dist/extension-guard-friction.js +5 -5
  9. package/package.json +10 -2
  10. package/src/cli/ingest.js +311 -57
  11. package/src/cli/renderers/html-index-core.js +66 -12
  12. package/src/cli/renderers/html-index.js +7 -5
  13. package/src/cli/renderers/replay-assets.js +61 -7
  14. package/src/cli/renderers/replay-client-source.js +102 -0
  15. package/src/cli/renderers/replay-viewer.client.js +1750 -595
  16. package/src/cli/report.js +6 -0
  17. package/src/core/monitor.js +12 -13
  18. package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
  19. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  20. package/src/jspsych/extension-guard-friction.js +59 -1
  21. package/src/replay/capture-dom.js +470 -458
  22. package/src/replay/capture-trace.js +688 -271
  23. package/src/replay/delivery.js +82 -0
  24. package/src/replay/dom-instantiate.js +779 -0
  25. package/src/replay/index.js +88 -5
  26. package/src/replay/initial-state.js +295 -0
  27. package/src/replay/mutations.js +668 -0
  28. package/src/replay/node-registry.js +116 -0
  29. package/src/replay/persistence.js +19 -6
  30. package/src/replay/recorder.js +342 -73
  31. package/src/replay/redaction.js +165 -0
  32. package/src/replay/serializer.js +148 -43
  33. package/src/replay/snapshot.js +409 -0
  34. package/src/replay/span.js +55 -0
  35. package/src/replay/viewer-model.js +293 -102
  36. package/src/shared/constants.js +1 -1
  37. package/src/shared/inline-safe.js +80 -0
  38. package/src/shared/schema-v2-validator.js +595 -0
  39. 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, embedded verbatim into the HTML report by
3
- // html-index.js (readFileSync at render time — file://-safe, no fetch).
2
+ // Browser-side replay viewer for SessionRecording v2 (spec r2), embedded
3
+ // verbatim into the HTML report.
4
4
  //
5
- // Contract: window.initChReplayViewer(mountEl, model) where model is the
6
- // viewer model emitted by replay-assets.js (trial-relative event times,
7
- // per-trial camera seeds, marker attribute name, legacy flag).
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
- // Coordinate model (cursor-alignment fix):
10
- // The stage shows the RECORD-TIME VIEWPORT, not the document. A camera
11
- // state (window scroll + viewport size) is maintained at the playhead from
12
- // the trial's seed + its scroll/resize events; the iframe is sized to the
13
- // record-time layout (clientWidth/Height), scrolled to the record-time
14
- // scroll, and letterboxed into a fixed stage box. Cursor/trail draw in
15
- // CLIENT space: new recordings carry cx/cy per event; legacy recordings
16
- // re-project page coords minus the running scroll (reduced guarantees,
17
- // surfaced by a persistent banner).
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
- // Every anchored interaction runs an alignment self-check (marker
20
- // resolution + camera assertions + recorded-vs-replayed rect + containment
21
- // + hit-test). Failures NEVER stay silent: header status, lane marks, an
22
- // uncertain cursor glyph, and a terminated trail.
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 · trial <select> · play/pause · speed · clock · status chips
26
- // stage tier 'dom': sandboxed <iframe srcdoc> rebuilt per trial/seek
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-trial marker lane (clipboard/mutations/bands/check marks)
30
- // scrub <input type=range> in ms, wired both ways with the play loop
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', 'ch:drop': '#7b1fa2', copy: '#f57c00', cut: '#f57c00',
38
- 'ch:guard_violation': '#d32f2f', 'ch:capture_stopped': '#616161',
39
- mutation: '#1976d2', input: '#388e3c'
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
- // Keycast: how long a chip keeps fading after its keyup, in trial-relative
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 fallback: recorder.js's REPLAY_DEFAULTS, used
58
- // only when a stopped trial's own ch:capture_stopped event (the real
59
- // configured value for THIS recording) can't be found.
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
- var DISCRETE_KINDS = {
63
- mousedown: true, mouseup: true, click: true, touchstart: true, touchend: true
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. e.key already names special keys well
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
- // ── DOM reconstruction (tier 'dom') ──
92
- // The recorded DOM is UNTRUSTED (an adversarial participant or extension
93
- // may have injected markup). sandbox="" already blocks scripts; the CSP
94
- // meta below additionally kills frames, form posts, fetch/XHR-carrying
95
- // elements, and plugin content, while still allowing images/media/styles
96
- // and fonts — the carriers of visual fidelity. Residual (documented):
97
- // a participant-injected <img src> can still fire a GET to its host when
98
- // the analyst loads the replay. no-referrer strips the analyst context.
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
108
244
  }
109
245
 
110
- function buildSrcdoc(model, trial, allowExternalCss) {
111
- var styles = '';
112
- (model.stylesheets || []).forEach(function (sheet) {
113
- if (sheet.css) {
114
- styles += '<style>' + sheet.css.replace(/<\//g, '<\\/') + '</style>';
115
- } else if (allowExternalCss && sheet.href && /^https?:/i.test(sheet.href)) {
116
- styles += '<link rel="stylesheet" href="' + attrEscape(sheet.href) + '">';
117
- }
118
- });
119
- // NOTE deliberately ABSENT: the old `html,body{margin:0}` override. It
120
- // came AFTER the captured CSS and silently overrode the page's own body
121
- // margins (including `margin:auto` centering) — the single largest
122
- // cursor-misalignment source on the acceptance fixture (~390px). The
123
- // reconstruction must lay out exactly as the recorded page did, UA
124
- // defaults included.
125
- // The frame is sized to the RECORD-TIME layout width (clientWidth =
126
- // innerWidth minus any classic scrollbar), so the frame's own root
127
- // scrollbar must not subtract a second gutter on classic-scrollbar
128
- // platforms. Hiding the ROOT scroller's bar (scrolling still works
129
- // programmatically) keeps layout width == camera width everywhere;
130
- // inner containers keep their bars.
131
- var gutterFix = '<style>html{scrollbar-width:none}' +
132
- 'html::-webkit-scrollbar{width:0;height:0}</style>';
133
- return '<!DOCTYPE html><html><head><meta charset="utf-8">' +
134
- '<meta http-equiv="Content-Security-Policy" content="' + srcdocCsp(allowExternalCss) + '">' +
135
- '<meta name="referrer" content="no-referrer">' + gutterFix + styles +
136
- '</head><body>' + (trial.initialDom || '') + '</body></html>';
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
- function resolvePath(root, path) {
140
- if (!path) return null;
141
- var cur = root;
142
- for (var i = 0; i < path.length; i++) {
143
- if (!cur || !cur.childNodes) return null;
144
- cur = cur.childNodes[path[i]];
145
- }
146
- return cur || null;
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
- window.initChReplayViewer = function (mount, model) {
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 trials = model.trials || [];
156
- if (trials.length === 0) {
157
- mount.appendChild(el('p', 'replay-note', 'Recording contains no trials.'));
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 scrollbar delta for legacy resize events (see buildViewerModel).
161
- var sbW = (model.scrollbar && model.scrollbar.w) || 0;
162
- var sbH = (model.scrollbar && model.scrollbar.h) || 0;
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 trialIdx = 0;
166
- var playhead = 0; // trial-relative ms
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 trial's end while
170
- // playing, load the next trial and keep going, so an analyst watches a
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 trial boundaries' toggle flips this off, restoring the
173
- // original per-trial-stop behavior.
327
+ // 'pause at segment boundaries' toggle flips this off.
174
328
  var autoAdvance = true;
175
- var appliedIdx = 0; // events applied so far (in trial event order)
176
- var iframeGen = 0; // increments per rebuild; stale onloads bail
177
- var domReady = false;
178
- var markerMap = new Map(); // marker id (string) → element, per frame gen
179
- var counters = { patchFailures: 0, scrollFailures: 0 };
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
- var pendingCamSize = false; // viewport dims changed since last flush
183
- var stageW = 0, stageH = 0; // stage box (fixed per trial)
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
- function trial() { return trials[trialIdx]; }
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 seedCamera() {
189
- var seed = trial().camera || {};
190
- cam = {
191
- x: seed.x || 0, y: seed.y || 0,
192
- w: seed.w || 1280, h: seed.h || 800,
193
- cw: seed.cw || seed.w || 1280, ch: seed.ch || seed.h || 800
194
- };
195
- }
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 trialSel = el('select', 'replay-trial-select');
203
- trials.forEach(function (t, i) {
376
+ var segSel = el('select', 'replay-segment-select');
377
+ segments.forEach(function (s, i) {
204
378
  var opt = el('option', null,
205
- 'Trial ' + (t.index != null ? t.index : i) + ' — ' + (t.id || '?') +
206
- ' (' + fmtClock(t.durMs) + ')');
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
- trialSel.appendChild(opt);
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 trial boundaries'));
402
+ pauseLabel.appendChild(document.createTextNode(' Pause at segment boundaries'));
228
403
  header.appendChild(badge);
229
- header.appendChild(trialSel);
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 (trials.length > 1) header.appendChild(pauseLabel);
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
- if (model.captureStopped) {
240
- // The real configured caps for THIS recording live on the trial's own
241
- // ch:capture_stopped marker (recorder.js writes it with the limit that
242
- // was actually crossed) — read those instead of assuming the library
243
- // defaults, since maxEventsPerTrial/maxCharsPerTrial are configurable.
244
- var capEventsLimit = null, capCharsLimit = null, capTrialsHit = 0;
245
- trials.forEach(function (t) {
246
- var hit = false;
247
- (t.events || []).forEach(function (e) {
248
- if (e.kind !== 'ch:capture_stopped') return;
249
- hit = true;
250
- if (e.limit != null && capEventsLimit == null) capEventsLimit = e.limit;
251
- if (e.limit_chars != null && capCharsLimit == null) capCharsLimit = e.limit_chars;
252
- });
253
- if (hit) capTrialsHit++;
254
- });
255
- if (capEventsLimit == null) capEventsLimit = CAP_DEFAULTS.events;
256
- if (capCharsLimit == null) capCharsLimit = CAP_DEFAULTS.chars;
257
- var capTrialsCount = capTrialsHit || 1;
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
- capSummary.textContent = 'Capture stopped early in ' + capTrialsCount +
263
- ' trial' + (capTrialsCount === 1 ? '' : 's') + ' (buffer cap, details)';
264
- var capBody = el('p', null,
265
- 'The recorder caps each trial at ' + fmtCount(capEventsLimit) + ' events or about ' +
266
- fmtCount(capCharsLimit) + ' characters, whichever comes first (the character count ' +
267
- 'starts from that trial’s initial DOM snapshot). Once a trial crosses either cap, ' +
268
- 'that trial stops recording and a marker is written; the next trial starts capturing ' +
269
- 'fresh. The caps limit the damage one runaway trial can do. They do not bound the whole ' +
270
- 'session: a recording with many trials can still add up to a large file. Both caps are ' +
271
- 'configurable when the recorder is attached (maxEventsPerTrial, maxCharsPerTrial).');
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(capBody);
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
- if (model.legacy) {
282
- var legacyChip = el('span', 'replay-note replay-warn',
283
- 'Legacy recording: alignment guarantees are reduced (coordinates re-projected, unverified)');
284
- legacyChip.setAttribute('data-ch-legacy', '');
285
- header.appendChild(legacyChip);
286
- }
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. Both set per trial / model.
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 = false;
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
- if (iframe) rebuildFrame(playhead);
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
- // by the sandbox and by the srcdoc CSP), but the document keeps a
345
- // reachable origin so the player can apply mutation/input state via
346
- // contentDocument. sandbox="" would make the origin opaque and freeze
347
- // every dom-tier replay at its first frame.
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
- // act on live untrusted DOM (submit a form, navigate the frame).
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 above (a pure function of
368
- // playhead, drawn alongside them — see drawKeycast). keys:'off'
369
- // recordings carry no keydown/keyup events, so this stays empty; that
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
- // ── Stage geometry ──
396
- // The stage BOX is fixed per trial (sized from the trial's seed camera
397
- // aspect) so playback never reflows the report page; camera changes
398
- // re-letterbox the iframe INSIDE the box.
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 = trial().camera || {};
402
- var scw = seed.cw || seed.w || 1280;
403
- var sch = seed.ch || seed.h || 800;
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
- // Apply the current camera's viewport dims to the iframe + transform.
414
- // Called lazily: before each anchored self-check and once per applied
415
- // batch — a 240-event resize storm costs a handful of real reflows, not
416
- // 240 (Sol round-1 finding 11).
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
- if (!iframe || !domReady) return;
434
- var doc = iframe.contentDocument;
435
- if (doc && doc.defaultView && typeof doc.defaultView.scrollTo === 'function') {
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
- // ── Marker harvesting ──
441
- // Markers arrive as data-chn-<nonce> attributes in the serialized HTML.
442
- // They are harvested into an out-of-band Map and REMOVED immediately, so
443
- // no marker attribute ever participates in a layout we measure and page
444
- // CSS/attributes can never hijack resolution.
445
- function harvestMarkers(scope) {
446
- if (!model.markerAttr || !scope || !scope.querySelectorAll) return;
447
- var found;
448
- try { found = scope.querySelectorAll('[' + model.markerAttr + ']'); }
449
- catch (e) { return; }
450
- for (var i = 0; i < found.length; i++) {
451
- var node = found[i];
452
- markerMap.set(node.getAttribute(model.markerAttr), node);
453
- node.removeAttribute(model.markerAttr);
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
- if (scope.getAttribute && scope.getAttribute(model.markerAttr) != null) {
456
- markerMap.set(scope.getAttribute(model.markerAttr), scope);
457
- scope.removeAttribute(model.markerAttr);
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
- // LATCHED per trial selection: an iframe (or shadow host) that existed at
462
- // ANY point in the trial keeps its warning even after a removal patch —
463
- // the invisible interactions don't become visible because the frame left.
464
- var trialHadIframe = false;
465
- var trialHadShadow = false;
466
- function updateIframeWarning(doc) {
467
- try {
468
- if (doc && doc.querySelector('[data-ch-iframe]')) trialHadIframe = true;
469
- if (doc && doc.querySelector('[data-ch-shadow]')) trialHadShadow = true;
470
- } catch (e) { /* keep latches */ }
471
- iframeChip.style.display = trialHadIframe ? '' : 'none';
472
- shadowChip.style.display = trialHadShadow ? '' : 'none';
473
- }
474
-
475
- // Resolve a mutation/anchor reference: marker first (validated by tag +
476
- // connectedness), then the legacy child-index path, then raw id. The
477
- // path fallback is TAG-VALIDATED whenever the reference carries an
478
- // expected tag: a failed marker means the reconstruction diverged (e.g.
479
- // the parser dropped an invalid-but-real construct like nested forms),
480
- // and an unvalidated raw-index path would silently patch whatever node
481
- // now sits at those indices. Legacy references (no tag) keep the
482
- // original unvalidated behavior.
483
- function resolveRef(doc, ref) {
484
- if (ref.n != null) {
485
- var byMarker = markerMap.get(String(ref.n));
486
- if (byMarker && byMarker.isConnected &&
487
- (!ref.tag || byMarker.tagName.toLowerCase() === ref.tag)) {
488
- return byMarker;
489
- }
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 byPath = resolvePath(doc.body, ref.path);
492
- if (byPath && (!ref.tag ||
493
- (byPath.tagName && byPath.tagName.toLowerCase() === ref.tag))) {
494
- return byPath;
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 (ref.id && doc.getElementById) {
497
- var byId = doc.getElementById(ref.id);
498
- if (byId && (!ref.tag || byId.tagName.toLowerCase() === ref.tag)) return byId;
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
- return null;
1179
+ style.textContent = '[' + CANVAS_ATTR + '="' + id + '"]{' + css + '}';
501
1180
  }
502
1181
 
503
- // ── DOM state at playhead ──
504
- function rebuildFrame(thenSeekTo, isRevalidation) {
505
- if (!iframe) return;
506
- iframeGen++;
507
- var gen = iframeGen;
508
- domReady = false;
509
- appliedIdx = 0;
510
- markerMap = new Map();
511
- counters.patchFailures = 0;
512
- counters.scrollFailures = 0;
513
- seedCamera();
514
- pendingCamSize = true;
515
- // Reset check caches — they are recomputed deterministically during
516
- // forward re-application.
517
- trial().events.forEach(function (e) { if (e.__chk) delete e.__chk; });
518
- iframe.onload = function () {
519
- if (gen !== iframeGen) return; // superseded by a newer rebuild
520
- domReady = true;
521
- var doc = iframe.contentDocument;
522
- harvestMarkers(doc && doc.body);
523
- updateIframeWarning(doc);
524
- flushCamSize();
525
- applyCamScroll();
526
- applyDomUpTo(thenSeekTo);
527
- drawOverlay();
528
- drawKeycast();
529
- updateStatusChips();
530
- // Late-layout revalidation: web fonts settling after the first
531
- // layout can shift geometry and stale an initially-passing check.
532
- // One revalidation pass per user-initiated rebuild (guarded against
533
- // rebuild→fonts.ready→rebuild loops).
534
- if (!isRevalidation && doc && doc.fonts && doc.fonts.ready &&
535
- typeof doc.fonts.ready.then === 'function') {
536
- doc.fonts.ready.then(function () {
537
- if (gen === iframeGen) rebuildFrame(playhead, true);
538
- }).catch(function () { /* fonts API absent/failed — skip */ });
539
- }
540
- };
541
- iframe.srcdoc = buildSrcdoc(model, trial(), allowExternalCss);
542
- }
543
-
544
- // Restores form state from an 'input' event. Form values are element
545
- // PROPERTIES — mutation snapshots never carry them — so without this
546
- // step every replayed form would look untouched.
547
- function applyInput(doc, e) {
548
- var target = null;
549
- var wantTag = e.el ? String(e.el).split('#')[0].toUpperCase() : null;
550
- // Marker first: getElementById returns the FIRST duplicate, which can
551
- // restore the typed value into the wrong control silently.
552
- if (e.n != null) {
553
- target = resolveRef(doc, {
554
- n: e.n, tag: wantTag ? wantTag.toLowerCase() : null, path: null, id: null
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
- if (!target && e.id) {
558
- var byId = doc.getElementById(e.id);
559
- if (byId && (!wantTag || byId.tagName === wantTag)) target = byId;
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
- if (!target && e.el) {
562
- try { target = doc.querySelector(e.el); } catch (err) { /* bad selector */ }
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
- target.value = new Array((e.value_len || 0) + 1).join('•');
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
- if (e.checked != null) target.checked = e.checked;
571
- }
572
-
573
- function applyMutation(doc, patch) {
574
- var target = resolveRef(doc, patch);
575
- if (!target) { counters.patchFailures++; return; }
576
- if (patch.op === 'childList') {
577
- target.innerHTML = patch.html;
578
- harvestMarkers(target);
579
- updateIframeWarning(doc);
580
- } else if (patch.op === 'attributes') {
581
- // Refuse event-handler and malformed attribute names even if an
582
- // artifact (older recorder, third-party #3661 file) carries them.
583
- if (!/^[a-zA-Z_:][-a-zA-Z0-9_:.]*$/.test(patch.name) || /^on/i.test(patch.name)) return;
584
- if (patch.value === null) target.removeAttribute && target.removeAttribute(patch.name);
585
- else target.setAttribute && target.setAttribute(patch.name, patch.value);
586
- } else if (patch.op === 'characterData') {
587
- target.textContent = patch.value;
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
- // Element scroll restore (window scrolls are camera state, handled in
592
- // the application loop). Redacted scrolls are unresolvable BY DESIGN.
593
- function applyElementScroll(doc, e) {
594
- if (e.redacted) return;
595
- var target = null;
596
- if (e.n != null || e.id) {
597
- target = resolveRef(doc, { n: e.n, id: e.id, tag: null, path: null });
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
- if (!target && e.el) {
600
- try { target = doc.querySelector(e.el); } catch (err) { /* bad selector */ }
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 (!target) { counters.scrollFailures++; return; }
603
- try { target.scrollTop = e.y || 0; target.scrollLeft = e.x || 0; }
604
- catch (err) { counters.scrollFailures++; }
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 discrete event) ──
608
- // Compares INDEPENDENT coordinate systems (Sol round-2 finding 10):
609
- // camera assertions — applied scroll/dims vs requested (±1px)
610
- // rect — recorded client rect vs replayed rect (±3px/edge)
611
- // containment — projected point inside replayed rect (2px)
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 (±2px)
614
- // Any failed predicate ⇒ status 'uncertain' with reasons, surfaced by
615
- // chips + lane + glyph. Redacted anchors are 'redacted' (unverifiable).
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
- if (!iframe || !domReady) return;
618
- var doc = iframe.contentDocument;
619
- if (!doc) return;
620
- var t = e.target;
621
- if (t.redacted) { e.__chk = { status: 'redacted', reasons: ['redacted target'] }; return; }
622
- var reasons = [];
623
- // Event-time shadow retargeting (recorder saw a composed path deeper
624
- // than e.target): the visible thing that was clicked is not in the
625
- // reconstruction, whatever the host's geometry says. Latches the
626
- // trial-level warning too — a post-snapshot attachShadow() leaves no
627
- // serialized marker for updateIframeWarning to find.
628
- if (t.shadow) {
629
- reasons.push('shadow content not captured');
630
- trialHadShadow = true;
631
- updateIframeWarning(doc);
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
- // camera assertions
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
- if (view) {
637
- if (Math.abs((view.scrollX || 0) - cam.x) > TOL_CAMERA ||
638
- Math.abs((view.scrollY || 0) - cam.y) > TOL_CAMERA) {
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 ' + cam.x + ',' + cam.y + ')');
1670
+ (view.scrollX || 0) + ',' + (view.scrollY || 0) + ' vs ' + wantX + ',' + wantY + ')');
641
1671
  }
642
- var de = doc.documentElement;
643
- if (de && Math.abs(de.clientWidth - cam.cw) > TOL_CAMERA) {
644
- reasons.push('frame layout width ' + de.clientWidth + ' vs camera ' + cam.cw);
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
- // anchor resolution
649
- var target = null;
650
- if (t.n != null || t.id) {
651
- target = resolveRef(doc, { n: t.n, id: t.id, tag: t.tag, path: null });
652
- }
653
- if (!target) {
654
- reasons.push('anchor unresolved (' + (t.tag || '?') + (t.id ? '#' + t.id : '') + ')');
655
- } else if (target.hasAttribute && target.hasAttribute('data-ch-shadow')) {
656
- // The interaction retargeted to a shadow host whose content was not
657
- // captured — geometry would "verify" against a hollow box. Refuse.
658
- reasons.push('shadow content not captured');
659
- } else {
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
- // recorded vs replayed rect, all four edges (conjunctive)
663
- if (rr && t.rect) {
664
- var dL = Math.abs(rr.left - t.rect[0]);
665
- var dT = Math.abs(rr.top - t.rect[1]);
666
- var dR = Math.abs((rr.left + rr.width) - (t.rect[0] + t.rect[2]));
667
- var dB = Math.abs((rr.top + rr.height) - (t.rect[1] + t.rect[3]));
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
- // containment of the projected point
674
- var px = e.cx, py = e.cy;
675
- if (px == null) { px = (e.x || 0) - cam.x; py = (e.y || 0) - cam.y; }
676
- if (rr && !(px >= rr.left - TOL_CONTAIN && px <= rr.left + rr.width + TOL_CONTAIN &&
677
- py >= rr.top - TOL_CONTAIN && py <= rr.top + rr.height + TOL_CONTAIN)) {
678
- reasons.push('cursor outside target');
679
- }
680
- // independent hit-test
681
- if (doc.elementFromPoint && px != null) {
682
- var under = null;
683
- try { under = doc.elementFromPoint(px, py); } catch (err) { /* skip */ }
684
- if (under && under !== target &&
685
- !(target.contains && target.contains(under)) &&
686
- !(under.contains && under.contains(target))) {
687
- reasons.push('hit-test resolves ' +
688
- (under.id ? under.tagName.toLowerCase() + '#' + under.id : under.tagName.toLowerCase()));
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) { /* measuring failed — not evidence of misalignment */ }
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
- // Applies DOM patches AND folds camera state up to T. The camera fold
710
- // runs at BOTH tiers — trace-tier replays have no reconstruction but the
711
- // cursor projection still needs scroll/resize/snapshot state and the
712
- // stage transform. DOM operations are conditional on `doc`.
713
- function applyDomUpTo(T) {
714
- var doc = null;
715
- if (iframe) {
716
- if (!domReady) return;
717
- doc = iframe.contentDocument;
718
- if (!doc) return;
719
- }
720
- var events = trial().events;
721
- for (; appliedIdx < events.length; appliedIdx++) {
722
- var e = events[appliedIdx];
723
- if (e.t > T) break;
724
- if (e.kind === 'mutation') {
725
- if (doc) applyMutation(doc, e);
726
- } else if (e.kind === 'input') {
727
- if (doc) applyInput(doc, e);
728
- } else if (e.kind === 'scroll') {
729
- if (e.el != null || e.redacted) {
730
- if (doc) applyElementScroll(doc, e);
731
- } else {
732
- cam.x = e.x || 0; cam.y = e.y || 0;
733
- applyCamScroll();
734
- }
735
- } else if (e.kind === 'resize') {
736
- cam.w = e.w != null ? e.w : cam.w;
737
- cam.h = e.h != null ? e.h : cam.h;
738
- // Layout dims: recorded cw/ch when present; legacy events fall
739
- // back to the embedded visualViewport width, then to w minus the
740
- // session scrollbar delta — the SAME chain buildViewerModel uses
741
- // for cross-trial folding, so in-trial state can't diverge from
742
- // the seeds.
743
- cam.cw = e.cw != null ? e.cw
744
- : (e.vv && e.vv.width != null ? e.vv.width
745
- : (e.w != null ? e.w - sbW : cam.cw));
746
- cam.ch = e.ch != null ? e.ch
747
- : (e.vv && e.vv.height != null ? e.vv.height
748
- : (e.h != null ? e.h - sbH : cam.ch));
749
- pendingCamSize = true; // folded — applied lazily, not per event
750
- } else if (DISCRETE_KINDS[e.kind]) {
751
- // Camera snapshot on the interaction is AUTHORITATIVE: scroll and
752
- // resize notifications dispatch asynchronously after programmatic
753
- // scrolls, so the snapshot may know state whose notification event
754
- // arrives later in the stream (which then applies as a no-op).
755
- if (e.sx != null) {
756
- if (e.sx !== cam.x || e.sy !== cam.y) {
757
- cam.x = e.sx; cam.y = e.sy;
758
- applyCamScroll();
759
- }
760
- if (e.cw != null && (e.cw !== cam.cw || e.ch !== cam.ch)) {
761
- cam.cw = e.cw; cam.ch = e.ch;
762
- cam.w = e.vw != null ? e.vw : cam.w;
763
- cam.h = e.vh != null ? e.vh : cam.h;
764
- pendingCamSize = true;
765
- }
766
- }
767
- if (doc && e.target && e.__chk == null) {
768
- if (pendingCamSize) { flushCamSize(); applyCamScroll(); }
769
- evaluateCheck(e);
770
- }
771
- }
772
- }
773
- if (pendingCamSize) { flushCamSize(); applyCamScroll(); }
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 wasBehind = T < playhead;
778
- playhead = Math.max(0, Math.min(T, trial().durMs));
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
- // Backward seek: patches are state snapshots but attribute /
782
- // characterData patches aren't reversible — rebuild from the
783
- // initial DOM and re-apply forward. Cheap for typical trials.
784
- if (wasBehind) rebuildFrame(playhead);
785
- else applyDomUpTo(playhead);
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
- // Trace tier: no reconstruction, but the camera fold still runs so
788
- // the cursor projection tracks scroll/resize state.
789
- if (wasBehind) { seedCamera(); appliedIdx = 0; pendingCamSize = true; }
790
- applyDomUpTo(playhead);
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(trial().durMs);
1821
+ clock.textContent = fmtClock(playhead) + ' / ' + fmtClock(seg().durMs);
798
1822
  scrub.setAttribute('aria-valuetext',
799
- fmtClock(playhead) + ' of ' + fmtClock(trial().durMs));
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). New recordings carry
805
- // cx/cy; legacy events re-project page − runningScroll at each point's
806
- // OWN timestamp (the scan folds scroll state as it walks). Trail
807
- // segments break at camera discontinuities (resize) and at uncertain
808
- // interactions — a confident-looking trail must never bridge a state
809
- // change it can't vouch for.
810
- function projectEvent(e, scroll) {
811
- var cx = e.cx != null ? e.cx : (e.x || 0) - scroll.x;
812
- var cy = e.cy != null ? e.cy : (e.y || 0) - scroll.y;
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 = trial().events;
819
- var seed = trial().camera || {};
820
- var scroll = { x: seed.x || 0, y: seed.y || 0 };
821
- var segments = [[]]; // trail polyline segments (break on state change)
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
- if (e.kind === 'scroll' && e.el == null && !e.redacted) {
829
- scroll.x = e.x || 0; scroll.y = e.y || 0;
830
- if (e.cx == null) segments.push([]); // legacy trail: camera jump
831
- } else if (DISCRETE_KINDS[e.kind] && e.sx != null &&
832
- (e.sx !== scroll.x || e.sy !== scroll.y)) {
833
- // Interaction camera snapshots update the running scroll for the
834
- // legacy projection path too (async-notification gap).
835
- scroll.x = e.sx; scroll.y = e.sy;
836
- } else if (e.kind === 'resize') {
837
- segments.push([]); // camera discontinuity
838
- } else if (e.kind === 'mousemove' || e.kind === 'touchmove') {
839
- var p = projectEvent(e, scroll);
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) segments[segments.length - 1].push(p);
1872
+ if (e.t >= playhead - 2500) segs[segs.length - 1].push(p);
842
1873
  lastPos = p;
843
- } else if (e.kind === 'click' || e.kind === 'mousedown' || e.kind === 'touchstart') {
844
- var q = projectEvent(e, scroll);
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 !== 'ok');
848
- if (lastPosUncertain) segments.push([]); // sever the trail
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
- segments.forEach(function (seg) {
854
- for (var s = 1; s < seg.length; s++) {
855
- var age = (playhead - seg[s].t) / 2500;
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(seg[s - 1].x, seg[s - 1].y);
860
- ctx.lineTo(seg[s].x, seg[s].y);
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 keydown with its keyup to get each key's [down, up] window, then
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 e.code (handles
904
- // overlapping holds, e.g. Shift+A); redacted pairs carry no code (the
905
- // identity itself is withheld), so they match FIFO against each other —
906
- // an approximation that assumes redacted keystrokes don't overlap, true
907
- // for the single-field-typing case this exists to cover.
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 = trial().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.kind !== 'keydown' && e.kind !== 'keyup') continue;
1948
+ if (e.type !== 'key.down' && e.type !== 'key.up') continue;
917
1949
  if (e.redacted) {
918
- if (e.kind === 'keydown') {
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.kind === 'keydown') {
929
- // Auto-repeat sends keydown without an intervening keyup; keep the
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 trial; redrawn per seek so check marks appear) ──
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 dur = Math.max(trial().durMs, 1);
967
- var events = trial().events;
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.kind === 'blur' || (e.kind === 'visibility' && e.hidden)) {
2005
+ if (e.type === 'blur' || e.type === 'visibility.hidden') {
973
2006
  if (awayStart === null) awayStart = e.t;
974
- } else if (e.kind === 'focus' || (e.kind === 'visibility' && !e.hidden)) {
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 trial end
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.kind];
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 !== 'ok') color = UNCERTAIN_COLOR;
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 = trial().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
- alignChip.textContent = '⚠ ' + s.uncertain + ' interaction(s) failed the alignment self-check';
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
- (s.redacted > 0 ? ', ' + s.redacted + ' redacted' : '') + ')';
1027
- alignChip.className = 'replay-note';
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
- var fails = counters.patchFailures + counters.scrollFailures;
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 = trial().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.kind === 'mousemove' || e.kind === 'touchmove') continue;
1047
- var label = e.kind;
1048
- if (e.kind === 'paste' || e.kind === 'ch:drop') label += ' (' + (e.len == null ? '?' : e.len) + ' ch)';
1049
- if (e.kind === 'keydown' && e.key) label += ' ' + JSON.stringify(e.key);
1050
- if (e.kind === 'input') label += e.redacted ? ' [redacted, ' + e.value_len + ' ch]' : (' "' + String(e.value || '').slice(0, 24) + '"');
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 >= trial().durMs) {
1066
- if (autoAdvance && trialIdx < trials.length - 1) {
1067
- loadTrial(trialIdx + 1); // continue playing into the next trial
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 >= trial().durMs) seek(0);
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 () { seek(Number(scrub.value), true); });
2218
+ scrub.addEventListener('input', function () { requestSeek(Number(scrub.value)); });
1095
2219
  scrub.addEventListener('keydown', function (ev) {
1096
- var step = trial().durMs * 0.05;
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(trial().durMs); ev.preventDefault(); }
2224
+ else if (ev.key === 'End') { seek(seg().durMs); ev.preventDefault(); }
1101
2225
  });
1102
2226
  function updateSessionPos() {
1103
- sessionPos.textContent = 'Trial ' + (trialIdx + 1) + ' of ' + trials.length;
2227
+ sessionPos.textContent = 'Segment ' + (segIdx + 1) + ' of ' + segments.length;
1104
2228
  }
1105
2229
 
1106
- // Loads trial i's DOM/camera/scrub state WITHOUT touching playing/playBtn
1107
- // — the continuous-playback boundary in tick() below calls this directly
1108
- // so play keeps running across the cut; selectTrial() (manual trial-pick
1109
- // and boot) wraps it with the playing=false reset a user-driven jump
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 loadTrial(i) {
1112
- trialIdx = i;
1113
- trialSel.value = String(i);
1114
- scrub.max = String(trial().durMs);
1115
- trialHadIframe = false;
1116
- trialHadShadow = false;
1117
- seedCamera();
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
- // DPR advisory: resolution-conditional CSS cannot be reproduced in an
1120
- // iframe that inherits the analyst's DPR (declared limitation, loud).
1121
- var recDpr = (trial().camera && trial().camera.dpr) ||
1122
- (model.viewport && model.viewport.dpr) || null;
1123
- if (recDpr && window.devicePixelRatio && recDpr !== window.devicePixelRatio) {
1124
- dprChip.textContent = 'Recorded at DPR ' + recDpr + ', now viewing at DPR ' +
1125
- window.devicePixelRatio + '. Resolution-dependent styling may differ.';
1126
- dprChip.style.display = '';
1127
- } else {
1128
- dprChip.style.display = 'none';
1129
- }
1130
- if (iframe) {
1131
- rebuildFrame(0);
1132
- } else {
1133
- appliedIdx = 0;
1134
- pendingCamSize = true;
1135
- flushCamSize(); // trace tier: transform initializes from the seed
1136
- }
1137
- seek(0);
2245
+ if (shellReady || !iframe) restore(i, 0);
2246
+ scrub.value = '0';
2247
+ redraw();
1138
2248
  updateSessionPos();
1139
2249
  }
1140
- function selectTrial(i) {
2250
+ function selectSegment(i) {
1141
2251
  playing = false;
1142
2252
  playBtn.textContent = '▶';
1143
2253
  playBtn.setAttribute('aria-label', 'Play');
1144
- loadTrial(i);
2254
+ loadSegment(i);
1145
2255
  }
1146
- trialSel.addEventListener('change', function () {
1147
- selectTrial(Number(trialSel.value) || 0);
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 (Sol round-1 finding 11).
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; not a public API).
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
- selectTrial: selectTrial,
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 trial().events
2289
+ return seg().events
1172
2290
  .filter(function (e) { return e.__chk; })
1173
2291
  .map(function (e) {
1174
- return { t: e.t, kind: e.kind, status: e.__chk.status, reasons: e.__chk.reasons };
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 () { return { patchFailures: counters.patchFailures, scrollFailures: counters.scrollFailures }; },
1179
- frameReady: function () { return iframe ? domReady : true; }
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
- selectTrial(0);
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
  })();