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,22 +1,78 @@
1
1
  // src/replay/capture-trace.js
2
2
  // Tier-1 ("trace") capture: pointer, keys, clipboard, input values, scroll,
3
- // touch, focus/visibility, viewport. Attaches everything through the
4
- // recorder's listener registry so destroy() tears it all down.
3
+ // touch, focus/visibility, viewport — in the SessionRecording v2 event
4
+ // vocabulary (spec §5). Everything attaches through the recorder's listener
5
+ // registry so destroy() tears it all down.
5
6
  //
6
- // Coordinate contract (cursor-alignment fix): every pointer event
7
- // carries BOTH page coords (x/y — document space, survives any later scroll
8
- // math) and client coords (cx/cy — viewport space, what the viewer actually
9
- // draws). Discrete interactions additionally carry a target anchor and are
10
- // preceded by a synchronous flush of pending camera state (scroll/resize),
11
- // so the event order on the wire can never invert the state the interaction
12
- // happened under.
7
+ // What v2 changed here, and why it is one change rather than a rename pass:
13
8
  //
14
- // Testability: the environment (doc/win/now/raf) is injectable. In the
15
- // browser, callers omit `env` and the real globals are used.
9
+ // - **Dotted `type` names** (spec §5) replace v1's flat `kind`, and the
10
+ // payload under each name is that type's own shape, not a bag of fields
11
+ // merged onto an event. Hence `rec.pushRecord(record, t)`, which took over
12
+ // from v1's flat `pushEvent(kind, payload, t)` merge.
13
+ // - **Client-frame coordinates only** (spec §7 makes the client frame
14
+ // normative). v1 carried both frames — page in `x`/`y`, client in
15
+ // `cx`/`cy` — because its viewer did the projection itself; page positions
16
+ // now derive from the scroll state that `scroll.window`, `initial_state`
17
+ // and the camera block already carry.
18
+ // - **Integer node ids** (spec §4/§7) replace the nonce-marker refs. An
19
+ // element is addressable only if the FILE contains it, which is a question
20
+ // about the recording and not about the live DOM — so it is answered by the
21
+ // capture span's delivery model (delivery.js), the same source the mutation
22
+ // mapper and the keyframe seed use.
23
+ // - **Alignment blocks** (spec §6): the camera state and the target anchor
24
+ // move from flat `sx/sy/vw/vh/cw/ch` + `{tag,id,rect,n}` into complete
25
+ // `camera` and `anchor` objects, with the `dpr`/`vv_*` fields v1 never had.
26
+ // - **Viewport changes leave the event stream**: spec §2 keeps resize and
27
+ // pinch state in the session-level `viewport_changes` array, so those two
28
+ // channels push to the recorder's session sink instead of a trial.
29
+ //
30
+ // Three floors decide what a payload may say about an element. Every channel
31
+ // asks all three, which is the point of stating them once:
32
+ //
33
+ // 1. REDACTION (spec §8) is a property of the FILE, not of one channel: a
34
+ // redacted subtree's content appears nowhere, so key identity, input
35
+ // values, clipboard payloads and anchor ids collapse to the spec's
36
+ // redacted variants. Where §5 defines no redacted variant for a type
37
+ // (input.checked, input.select, scroll.element), the event is dropped
38
+ // instead — the same subtraction initial-state.js makes for the same
39
+ // reason.
40
+ // 2. EXCLUSION (spec §4) is stronger and was MISSING in v1: the file holds a
41
+ // placeholder with no attributes, children or content, so no channel may
42
+ // report the element's STATE either — not a typed value, not its length,
43
+ // not a scroll offset. v1's input handler checked redaction and never
44
+ // exclusion, which shipped the typed contents of CH's own guard bait as
45
+ // plaintext `input` events (the honeypot stamps its marker on the bait
46
+ // INPUT itself, so the whole subtree is one element).
47
+ // 3. ADDRESSABILITY: `input.*` and `scroll.element` are typed with a REQUIRED
48
+ // `node` (spec §5.2/§5.6). An event naming a node the player never
49
+ // received is worse than a missing event — it either does nothing or
50
+ // lands on the wrong element — so those types are emitted only when the
51
+ // target resolves. On trace tier (no DOM capture at all) nothing resolves,
52
+ // and the recording is honestly node-free.
53
+ //
54
+ // Both floors are asked about the RETARGETED target and the composed one, so a
55
+ // field inside an open shadow root cannot escape them (see `composedTarget`),
56
+ // and both verdicts are computed once per event (see `targetFacts`) because
57
+ // each one costs an ancestor walk and CH's studies are typing-heavy.
58
+ //
59
+ // Testability: the environment (doc/win/now/raf) is injectable, and so is the
60
+ // capture span. In the browser, callers omit `env` and the real globals are
61
+ // used.
16
62
  //
17
63
  // Failure containment: every handler body runs inside guard() — a throwing
18
64
  // capture channel logs one captureFailure and the experiment continues.
19
65
 
66
+ import {
67
+ isRedacted, isRedactionTainted, markRedacted,
68
+ } from './redaction.js';
69
+ import { isExcluded } from './snapshot.js';
70
+
71
+ // The per-node predicates, not the per-subtree ones: this module walks the
72
+ // parent chain ONCE for all three floors (see `floorsFor`), so it asks each
73
+ // predicate the question it can answer about a single node.
74
+ var ELEMENT_NODE = 1;
75
+
20
76
  // Wrap a handler so an exception can never propagate into the host page.
21
77
  function guard(rec, channel, fn) {
22
78
  return function (e) {
@@ -28,40 +84,27 @@ function guard(rec, channel, fn) {
28
84
  };
29
85
  }
30
86
 
31
- // Compact element descriptor for event payloads: "input#answer", "textarea".
32
- function describeEl(el) {
33
- if (!el || !el.tagName) return null;
34
- var d = el.tagName.toLowerCase();
35
- if (el.id) d += '#' + el.id;
36
- return d;
37
- }
38
-
39
- function isPassword(el) {
40
- return !!(el && el.tagName === 'INPUT' && el.type === 'password');
41
- }
42
-
43
- function matchesRedact(el, selector) {
44
- if (isPassword(el)) return true; // unconditional, not overridable
45
- if (!selector || !el || typeof el.matches !== 'function') return false;
46
- try { return el.matches(selector); } catch (e) { return false; }
47
- }
48
-
49
- // Subtree semantics, matching capture-dom's isInRedactedSubtree: a click on
50
- // a descendant of a redacted container must not leak the descendant's
51
- // identity or geometry through an anchor.
52
- function inRedactedSubtree(el, selector) {
53
- var cur = el;
54
- while (cur) {
55
- if (matchesRedact(cur, selector)) return true;
56
- cur = cur.parentNode;
57
- }
58
- return false;
59
- }
60
-
61
87
  // Round to 0.1px — anchor rects feed pixel-tolerance checks; full float
62
88
  // precision would just bloat the JSON.
63
89
  function r10(v) { return Math.round(v * 10) / 10; }
64
90
 
91
+ // Spec §5/§6 type every coordinate and camera field as a number. Real events
92
+ // and real windows always supply them; a duck-typed fixture may not, and a
93
+ // NaN or null on the wire fails strict validation instead of degrading.
94
+ function intOr0(v) { return typeof v === 'number' && isFinite(v) ? Math.round(v) : 0; }
95
+ // Same reason for the string fields spec §5.2 requires: an absent `key` is
96
+ // better said as "" than as the word "undefined".
97
+ function strOrEmpty(v) { return v == null ? '' : String(v); }
98
+
99
+ // Vendor namespace for event-level extensions (spec §9): CH-specific facts
100
+ // about a standard event, which may not ride as unknown top-level fields.
101
+ var CH_VENDOR = 'cyborg-hunter';
102
+ function withVendorExtension(record, data) {
103
+ var ext = record.extensions || (record.extensions = {});
104
+ ext[CH_VENDOR] = Object.assign({}, ext[CH_VENDOR], data);
105
+ return record;
106
+ }
107
+
65
108
  export function attachTraceCapture(rec, env) {
66
109
  env = env || {};
67
110
  var doc = env.doc || document;
@@ -72,70 +115,250 @@ export function attachTraceCapture(rec, env) {
72
115
  : function (fn) { setTimeout(fn, 16); });
73
116
  var config = rec.config;
74
117
 
118
+ // The capture span (span.js): node ids plus the file's own record of what it
119
+ // contains. THE SEAM — capture wiring (index.js) creates one span per
120
+ // recording and hands the same object to this module and to the DOM capture,
121
+ // so both speak about the file in the same ids. Absent (trace tier, or any
122
+ // caller that never took a keyframe) means nothing is addressable, which is
123
+ // exactly true of a recording with no DOM in it.
124
+ var span = env.span || null;
125
+
126
+ // The predicates' configuration, read once: config is fixed for the life of
127
+ // an attachment, and re-reading it per event bought nothing.
128
+ var opts = {
129
+ keepBait: config.keepBait,
130
+ redactSelector: config.redactSelector,
131
+ taint: env.taint,
132
+ };
133
+
134
+ // ── The three floors ──
135
+ //
136
+ // 1. REDACTION (spec §8) withholds CONTENT: a password field always, plus
137
+ // anything matching `redactSelector`, plus anything the taint set already
138
+ // marked (which is how content stays withheld after the page moves the
139
+ // element out of the redacted container — redaction.js).
140
+ // 2. EXCLUSION (spec §4) withholds an element's whole STATE, not just its
141
+ // content: not a typed value, not its length, not a scroll offset.
142
+ // `keepBait` still turns the LEGACY markers off, as everywhere.
143
+ // 3. ADDRESSABILITY: the nearest ancestor-or-self the FILE holds, with its
144
+ // node id. Spec §7's three cases fall out of one walk — a normal target
145
+ // resolves to itself; a target inside an excluded subtree resolves to the
146
+ // PLACEHOLDER, which is the node the file actually has; a target outside
147
+ // the observed root resolves to null. Membership comes from the span's
148
+ // delivery model rather than from re-deriving it off the live DOM, for the
149
+ // reason initial-state.js gives: the DOM can have moved since the walk
150
+ // that produced the file, and the file is what the player holds.
151
+ //
152
+ // All three are answered by `floorsFor` below, in ONE traversal.
153
+
154
+ // The REAL target of an event that crossed a shadow boundary, or null.
155
+ //
156
+ // At a document-level listener the browser retargets `e.target` to the shadow
157
+ // HOST, and both floor predicates walk `parentNode`, which stops at the
158
+ // ShadowRoot. So a password field inside an open shadow root answered "not
159
+ // redacted" and shipped its keystrokes, and spec §8's floor admits no
160
+ // exception: password values are never recorded, in any channel. §13's
161
+ // shadow-DOM limit governs what can be RECONSTRUCTED and licenses nothing out
162
+ // of a redacted field. `composedPath()[0]` is the node the interaction
163
+ // actually reached; CLOSED roots hide it, which is the same limit §13 already
164
+ // records.
165
+ function composedTarget(e) {
166
+ try {
167
+ if (!e || typeof e.composedPath !== 'function') return null;
168
+ var path0 = e.composedPath()[0];
169
+ return path0 && path0 !== e.target ? path0 : null;
170
+ } catch (err) { return null; } // composedPath unavailable
171
+ }
172
+
173
+ function nodeIdFor(el) { return floorsFor(el, true).node; }
174
+
175
+ // ── One walk, three verdicts ──
176
+ //
177
+ // Redaction (including its taint history), exclusion and addressability ask
178
+ // three different questions about THE SAME parent chain, and each used to
179
+ // walk it itself, through its own subtree helper: five traversals for an
180
+ // aligned key.down, and five again for every input flush. This fuses the
181
+ // traversal — one `while (cur = cur.parentNode)` carrying three accumulators.
182
+ // The exclusion helper it replaced (`isInExcludedSubtree`) had no callers
183
+ // left afterwards and is gone; the per-node predicates it wrapped are what
184
+ // this walk calls.
185
+ //
186
+ // It is FUSION, NOT CACHING, and the distinction is the whole point. Every
187
+ // verdict is still computed fresh from the live chain for every event, so
188
+ // there is no window in which a stale answer can survive an ancestor gaining
189
+ // the exclusion attribute, a class change flipping an arbitrary
190
+ // `redactSelector`, or the page moving the element into a withheld
191
+ // container. A cross-event `WeakMap<target, verdict>` would be materially
192
+ // cheaper and was rejected: it fails OPEN on both privacy floors, and a
193
+ // cached node id is silent misplacement. The per-ancestor work (`matches()`,
194
+ // the attribute reads, `delivery.holds`) is unchanged; only the repeated
195
+ // pointer-chasing is gone.
196
+ //
197
+ // The loop stops as soon as all three questions are answered, which is why
198
+ // the held lookup cannot simply return early on its own any more: the floors
199
+ // may still be undecided above it.
200
+ function floorsFor(node, wantHeld) {
201
+ var out = { redacted: false, excluded: false, node: null, heldEl: null };
202
+ if (!node) return out;
203
+ var redacted = false;
204
+ var excluded = false;
205
+ var haveHeld = !wantHeld || !span;
206
+ var cur = node;
207
+ while (cur && !(redacted && excluded && haveHeld)) {
208
+ // TAINT IS ASKED OF EVERY ANCESTOR, not just of the node (T3 final
209
+ // review, F-1): a field created inside a container whose content this
210
+ // recording already withheld is withheld too, which is the reading the
211
+ // snapshot walk has always used and the one that makes the taint set's
212
+ // "can only over-redact" property true of the file rather than of one
213
+ // channel. Unguarded by nodeType, unlike the selector match: text nodes
214
+ // carry taint as readily as elements.
215
+ if (!redacted && isRedactionTainted(cur, opts.taint)) redacted = true;
216
+ if (!redacted && cur.nodeType === ELEMENT_NODE
217
+ && isRedacted(cur, opts.redactSelector)) redacted = true;
218
+ if (!excluded && isExcluded(cur, opts)) excluded = true;
219
+ if (!haveHeld && span.delivery.holds(cur)) {
220
+ var id = span.registry.peekId(cur);
221
+ out.node = id == null ? null : id;
222
+ out.heldEl = cur;
223
+ haveHeld = true;
224
+ }
225
+ cur = cur.parentNode;
226
+ }
227
+ // Marking on the way out is what keeps redacted content withheld after the
228
+ // element leaves the redacted container (redaction.js); it belongs to the
229
+ // node asked about, exactly as the per-predicate version did.
230
+ if (redacted) markRedacted(node, opts.taint);
231
+ out.redacted = redacted;
232
+ out.excluded = excluded;
233
+ return out;
234
+ }
235
+
236
+ // Everything a discrete handler needs to know about its target, asked ONCE.
237
+ //
238
+ // Both floors are evaluated against the retargeted target AND the composed
239
+ // one, fail-closed, and both sides are evaluated (no short-circuit) so each
240
+ // marks its own redaction taint. Only the retargeted chain resolves the held
241
+ // node: it is the one the document tree, and therefore the file, contains.
242
+ function targetFacts(e) {
243
+ var el = e && e.target;
244
+ var composed = composedTarget(e);
245
+ var facts = floorsFor(el, true);
246
+ var redacted = facts.redacted;
247
+ var excluded = facts.excluded;
248
+ if (composed) {
249
+ var c = floorsFor(composed, false);
250
+ if (c.redacted) redacted = true;
251
+ if (c.excluded) excluded = true;
252
+ }
253
+ return {
254
+ el: el, composed: composed, redacted: redacted, excluded: excluded,
255
+ node: facts.node, heldEl: facts.heldEl
256
+ };
257
+ }
258
+
75
259
  function clientDims() {
76
260
  var de = doc.documentElement;
77
261
  return {
78
- cw: de && de.clientWidth ? de.clientWidth : (win.innerWidth || null),
79
- ch: de && de.clientHeight ? de.clientHeight : (win.innerHeight || null)
262
+ cw: de && de.clientWidth ? de.clientWidth : win.innerWidth,
263
+ ch: de && de.clientHeight ? de.clientHeight : win.innerHeight
80
264
  };
81
265
  }
82
266
 
83
- // Client coords appended to a payload when the event carries them (real
84
- // browsers always do; duck-typed test events may not).
85
- function withClient(payload, e) {
86
- if (e.clientX != null) payload.cx = Math.round(e.clientX);
87
- if (e.clientY != null) payload.cy = Math.round(e.clientY);
88
- return payload;
267
+ // Client-frame coordinates of a mouse event or a Touch point (spec §7).
268
+ function clientX(p) { return intOr0(p && p.clientX); }
269
+ function clientY(p) { return intOr0(p && p.clientY); }
270
+
271
+ // ── Alignment: camera (spec §6) ──
272
+ // Flushing pending camera state is not enough on its own: browsers dispatch
273
+ // scroll/resize NOTIFICATIONS asynchronously after programmatic scrolls
274
+ // (scrollIntoView, scrollTo, anchor jumps), so an interaction can be recorded
275
+ // before the scroll event that describes the state it happened under — with
276
+ // nothing pending to flush. The camera block reads the live state
277
+ // synchronously in the interaction's own handler.
278
+ //
279
+ // `dpr` and the `vv_*` fields complete the block per spec §6: zoom and pinch
280
+ // states become classifiable per event, independent of the coalesced
281
+ // viewport_changes stream.
282
+ function cameraBlock() {
283
+ var dims = clientDims();
284
+ var vv = win.visualViewport;
285
+ return {
286
+ scroll_x: intOr0(win.scrollX),
287
+ scroll_y: intOr0(win.scrollY),
288
+ viewport_w: intOr0(win.innerWidth),
289
+ viewport_h: intOr0(win.innerHeight),
290
+ client_w: intOr0(dims.cw),
291
+ client_h: intOr0(dims.ch),
292
+ dpr: typeof win.devicePixelRatio === 'number' ? win.devicePixelRatio : 1,
293
+ vv_scale: vv && typeof vv.scale === 'number' ? vv.scale : 1,
294
+ vv_offset_x: vv ? r10(vv.offsetLeft || 0) : 0,
295
+ vv_offset_y: vv ? r10(vv.offsetTop || 0) : 0
296
+ };
89
297
  }
90
298
 
91
- // ── Interaction anchor ──
92
- // Identity + geometry of what a discrete interaction hit, captured at
93
- // CAPTURE PHASE (before page handlers can remove the target or stop the
94
- // recording — the smoke fixture's Finish click was lost to exactly that).
95
- // Client-space rect: raw getBoundingClientRect, no scroll math — the
96
- // viewer compares it against the reconstruction's own client-space rect.
97
- // Redacted targets expose only {redacted, tag}: no id, rect, or marker,
98
- // so redaction never gains new leakage through anchors.
99
- function anchorFor(e) {
100
- var el = e && e.target;
299
+ // ── Alignment: anchor (spec §6) ──
300
+ // Identity + geometry of what a discrete interaction hit, captured at CAPTURE
301
+ // PHASE (before page handlers can remove the target or stop the recording —
302
+ // the smoke fixture's Finish click was lost to exactly that).
303
+ //
304
+ // `tag` and `rect` describe the EVENT TARGET, `node` names the nearest node
305
+ // the file holds. They can differ, and the target's own geometry is still the
306
+ // truthful answer to "what was under the pointer" — the player's cross-check
307
+ // is free to read the disagreement as uncertainty, which is what it is for.
308
+ // Describing the resolved node in every case would misreport a click on an
309
+ // element inserted and clicked within one task, before the observer flushed.
310
+ //
311
+ // ONE case is clamped to the resolved node: a target strictly INSIDE an
312
+ // excluded subtree. There, `tag` and `rect` would describe the interior of a
313
+ // subtree spec §4 keeps out of the file, and the file holds nothing to
314
+ // compare them against anyway. A click ON the excluded element needs no
315
+ // clamp, since §4 puts that element's own tag in the tree.
316
+ //
317
+ // And when there is NOTHING to clamp to — an excluded target with no held
318
+ // ancestor at all, which is trace tier (no DOM captured) or an excluded
319
+ // element outside the observed root — the whole anchor is dropped. The clamp
320
+ // has no node to describe, the file has nothing to cross-check against, and
321
+ // the alternative is shipping the tag and rectangle of exactly the subtree
322
+ // §4 withholds. Alignment fields are optional (§6), so saying nothing is a
323
+ // conforming answer; saying it about excluded content is the only honest one.
324
+ //
325
+ // `id` is OMITTED (not nulled) when identity is withheld — spec §6 says
326
+ // omitted for redacted targets, §4 says events inside an excluded subtree
327
+ // carry no anchor identity — so "no id attribute" (null) stays
328
+ // distinguishable from "withheld" (absent).
329
+ function anchorFor(facts) {
330
+ var el = facts.el;
101
331
  if (!el || !el.tagName) return null;
102
- var tag = el.tagName.toLowerCase();
103
- // inRedactedSubtree → matchesRedact, whose FIRST check is isPassword():
104
- // password inputs are unconditionally anchor-redacted, selector or not,
105
- // and so is anything inside a redactSelector-matched container.
106
- if (inRedactedSubtree(el, config.redactSelector)) {
107
- return { redacted: true, tag: tag };
108
- }
109
- var a = { tag: tag };
110
- // Shadow retargeting, detected at EVENT time: for OPEN shadow roots the
111
- // composed path's first entry is the real (shadow-internal) target while
112
- // e.target is the retargeted host — regardless of when attachShadow()
113
- // ran (a post-snapshot attach leaves no mutation record). The viewer
114
- // refuses to "verify" such interactions against the hollow host.
115
- // CLOSED roots are undetectable from outside by design — documented
116
- // limitation, same class as CSSOM-only content changes.
117
- try {
118
- if (typeof e.composedPath === 'function') {
119
- var path0 = e.composedPath()[0];
120
- if (path0 && path0 !== el) a.shadow = true;
121
- }
122
- } catch (err) { /* composedPath unavailable — snapshot marking still applies */ }
123
- if (el.id) a.id = el.id;
124
- var markers = typeof rec.getMarkers === 'function' ? rec.getMarkers() : null;
125
- if (markers) a.n = markers.refFor(el);
332
+ if (facts.excluded && !facts.heldEl) return null;
333
+ var described = (facts.excluded && facts.heldEl && facts.heldEl !== el)
334
+ ? facts.heldEl : el;
335
+ var a = { tag: (described.tagName || '').toLowerCase() };
336
+ if (!facts.redacted && !facts.excluded) a.id = described.id || null;
126
337
  try {
127
- if (typeof el.getBoundingClientRect === 'function') {
128
- var r = el.getBoundingClientRect();
129
- a.rect = [r10(r.left), r10(r.top), r10(r.width), r10(r.height)];
338
+ if (typeof described.getBoundingClientRect === 'function') {
339
+ var r = described.getBoundingClientRect();
340
+ a.rect = { x: r10(r.left), y: r10(r.top), w: r10(r.width), h: r10(r.height) };
130
341
  }
131
- } catch (e) { /* rect stays absent — viewer treats as unverifiable */ }
342
+ } catch (err) { /* rect stays absent — viewer treats as unverifiable */ }
343
+ a.node = facts.node;
132
344
  return a;
133
345
  }
134
346
 
347
+ // Shadow retargeting, noted for the player: the interaction reached a node no
348
+ // recording can contain (spec §13), so the anchor describes the host instead
349
+ // of what was really hit. That is CH's own note about a standard event, so it
350
+ // rides in the vendor namespace (spec §9) rather than as an extra anchor
351
+ // field. Open roots only; closed roots are undetectable from outside by
352
+ // design, which is the same limit §13 records.
353
+ function noteShadowRetarget(record, facts) {
354
+ if (facts.composed) withVendorExtension(record, { shadow_retarget: true });
355
+ return record;
356
+ }
357
+
135
358
  // ── Camera state: window/element scroll + viewport, RAF-coalesced ──
136
- // Two invariants the viewer's projection depends on:
137
- // 1. ORIGINAL timestamps: a coalesced event is stamped with the time of
138
- // the last underlying DOM event, not the RAF flush — otherwise a click
359
+ // Two invariants the player's projection depends on:
360
+ // 1. ORIGINAL timestamps: a coalesced event is stamped with the time of the
361
+ // last underlying DOM event, not the RAF flush — otherwise a click
139
362
  // recorded between the scroll and its flush sorts BEFORE the scroll and
140
363
  // plays back under the stale camera (full-scroll-jump error).
141
364
  // 2. Synchronous flush before discrete events: pending camera state is
@@ -151,224 +374,393 @@ export function attachTraceCapture(rec, env) {
151
374
  function flushScrolls() {
152
375
  scrollFlushQueued = false;
153
376
  if (pendingWindowScroll) {
154
- rec.pushEvent('scroll', withClientlessScroll({
155
- x: Math.round(win.scrollX || 0), y: Math.round(win.scrollY || 0)
156
- }), pendingWindowScroll.t);
377
+ rec.pushRecord({
378
+ type: 'scroll.window',
379
+ x: intOr0(win.scrollX), y: intOr0(win.scrollY)
380
+ }, pendingWindowScroll.t);
157
381
  pendingWindowScroll = null;
158
382
  }
159
383
  if (pendingElementScrolls.size > 0) {
160
384
  pendingElementScrolls.forEach(function (state, target) {
161
- var payload;
162
- if (inRedactedSubtree(target, config.redactSelector)) {
163
- // Same contract as interaction anchors: a scroller inside a
164
- // redacted subtree exposes no id, descriptor, or marker. Offsets
165
- // alone stay (they carry no content), unresolvable by design.
166
- payload = {
167
- redacted: true,
168
- tag: target.tagName ? target.tagName.toLowerCase() : null,
169
- x: Math.round(target.scrollLeft || 0),
170
- y: Math.round(target.scrollTop || 0)
171
- };
172
- } else {
173
- payload = {
174
- el: describeEl(target),
175
- id: target.id || null,
176
- x: Math.round(target.scrollLeft || 0),
177
- y: Math.round(target.scrollTop || 0)
178
- };
179
- var markers = typeof rec.getMarkers === 'function' ? rec.getMarkers() : null;
180
- if (markers) payload.n = markers.refFor(target);
181
- }
182
- rec.pushEvent('scroll', payload, state.t);
385
+ // An excluded element's scroll offset is part of the state spec §4
386
+ // withholds — the same call initial-state.js makes for the same
387
+ // element. A REDACTED one keeps its offsets: §8 withholds content, and
388
+ // an offset is not content; §5.6 defines no redacted variant to put it
389
+ // in; and the node reference is one spec §7 explicitly keeps.
390
+ //
391
+ // No composed-target check here, and not for lack of symmetry with the
392
+ // other channels: a `scroll` event is fired with neither `bubbles` nor
393
+ // `composed`, so a scroller inside a shadow root never reaches this
394
+ // window-level listener AT ALL. There is no retargeted scroll to
395
+ // handle — the case is a §13 capture limit (the offsets are simply not
396
+ // recorded), not a floor bypass. When the shadow HOST is itself the
397
+ // scroller, or a slotted light-DOM element is, the event fires on a
398
+ // document-tree node and the floors apply normally. The same fact is
399
+ // why `scrolledElements` can never hold a shadow-internal scroller, so
400
+ // the keyframe seed is consistent with this by construction.
401
+ var f = floorsFor(target, true);
402
+ if (f.excluded) return;
403
+ var node = f.node;
404
+ if (node == null) return; // spec §5.6 requires a node id
405
+ rec.pushRecord({
406
+ type: 'scroll.element', node: node,
407
+ x: intOr0(target.scrollLeft), y: intOr0(target.scrollTop)
408
+ }, state.t);
183
409
  });
184
410
  pendingElementScrolls.clear();
185
411
  }
186
412
  }
187
- // Window-scroll payloads carry no element fields; tiny helper keeps the
188
- // shape explicit rather than relying on undefined-stripping.
189
- function withClientlessScroll(p) { return p; }
190
413
 
191
- function flushResize() {
192
- resizeFlushQueued = false;
414
+ // Spec §2's ViewportState, complete: one shape for both channels, because
415
+ // `viewport_changes` entries are full states, not deltas, and a resize
416
+ // changes what pinch offsets mean as surely as a pinch does.
417
+ function viewportState() {
418
+ var vv = win.visualViewport;
419
+ return {
420
+ w: intOr0(win.innerWidth),
421
+ h: intOr0(win.innerHeight),
422
+ dpr: typeof win.devicePixelRatio === 'number' ? win.devicePixelRatio : 1,
423
+ scale: vv && typeof vv.scale === 'number' ? vv.scale : 1,
424
+ offset_x: vv ? r10(vv.offsetLeft || 0) : 0,
425
+ offset_y: vv ? r10(vv.offsetTop || 0) : 0
426
+ };
427
+ }
428
+
429
+ function emitResize() {
193
430
  if (!pendingResize) return;
194
- var dims = clientDims();
195
- rec.pushEvent('resize', {
196
- w: win.innerWidth, h: win.innerHeight,
197
- cw: dims.cw, ch: dims.ch,
198
- dpr: win.devicePixelRatio || 1
199
- }, pendingResize.t);
431
+ rec.pushViewportChange(viewportState(), pendingResize.t);
200
432
  pendingResize = null;
201
433
  }
202
434
 
203
- function flushVv() {
204
- vvFlushQueued = false;
435
+ function emitVv() {
205
436
  if (!pendingVv) return;
206
- var vv = win.visualViewport;
207
- if (vv) {
208
- rec.pushEvent('vv', {
209
- w: r10(vv.width), h: r10(vv.height), scale: vv.scale,
210
- px: r10(vv.pageLeft || 0), py: r10(vv.pageTop || 0)
211
- }, pendingVv.t);
212
- }
437
+ rec.pushViewportChange(viewportState(), pendingVv.t);
213
438
  pendingVv = null;
214
439
  }
215
440
 
441
+ // Both viewport channels write into ONE array (spec §2) whose entries keep
442
+ // their original timestamps, and `viewport_changes` must be time-sorted
443
+ // (spec §7). RAF coalescing makes the two channels' flush callbacks arrive in
444
+ // registration order rather than timestamp order, so BOTH callbacks drain
445
+ // through here.
446
+ //
447
+ // When both are pending they describe ONE state: each emit reads the live
448
+ // geometry, so the pair is field-identical and only its timestamp is in
449
+ // question. It gets the LATER of the two. The composite geometry became
450
+ // observable when the second notification arrived; stamping it with the first
451
+ // asserts that the new width existed before it did, and for a player
452
+ // classifying zoom or pinch, backdating a state misattributes every event in
453
+ // between. (Mobile keyboard-open and URL-bar transitions fire the two
454
+ // together, so this is an ordinary path, not a corner.) The error either way
455
+ // is bounded by one frame — the reason this is a one-liner and not a redesign.
456
+ function flushViewport() {
457
+ resizeFlushQueued = false;
458
+ vvFlushQueued = false;
459
+ if (pendingResize && pendingVv) {
460
+ var t = Math.max(pendingResize.t, pendingVv.t);
461
+ pendingResize = null;
462
+ pendingVv = null;
463
+ rec.pushViewportChange(viewportState(), t);
464
+ return;
465
+ }
466
+ emitResize();
467
+ emitVv();
468
+ }
469
+
216
470
  // Synchronous camera flush — called at the top of every discrete-event
217
471
  // handler so pending state is IN THE BUFFER before the interaction lands.
218
472
  // Emission order within this function (and Map iteration order) is
219
- // deliberately irrelevant: every event carries its ORIGINAL timestamp and
220
- // the wire order is established by the serializer's stable sort on t
221
- // (serializer.js serialize(); replay-assets.js buildViewerModel() sorts
222
- // again defensively). The buffer has never been chronologically ordered —
223
- // RAF-coalesced input events pre-date this and flush late the same way.
473
+ // deliberately irrelevant: every event carries its ORIGINAL timestamp and the
474
+ // wire order is established by the serializer's stable sort on t. The buffer
475
+ // has never been chronologically ordered — RAF-coalesced input events pre-date
476
+ // this and flush late the same way.
224
477
  function flushCameraNow() {
225
478
  flushScrolls();
226
- flushResize();
227
- flushVv();
479
+ flushViewport();
228
480
  }
229
481
 
230
- // Camera SNAPSHOT stamped onto every discrete interaction. Flushing alone
231
- // is not enough: browsers dispatch scroll/resize NOTIFICATIONS
232
- // asynchronously after programmatic scrolls (scrollIntoView, scrollTo,
233
- // anchor jumps), so a click can be recorded before the scroll event that
234
- // describes the state it happened under — with nothing pending to flush.
235
- // The snapshot reads the live state synchronously in the interaction's own
236
- // handler; the viewer treats it as an authoritative camera observation.
237
- function withCameraSnapshot(payload) {
238
- var dims = clientDims();
239
- payload.sx = Math.round(win.scrollX || 0);
240
- payload.sy = Math.round(win.scrollY || 0);
241
- payload.vw = win.innerWidth || null;
242
- payload.vh = win.innerHeight || null;
243
- payload.cw = dims.cw;
244
- payload.ch = dims.ch;
245
- return payload;
482
+ // Every discrete interaction carries both alignment blocks or neither (spec
483
+ // §6: complete blocks, no delta encoding).
484
+ function withAlignment(record, facts) {
485
+ record.camera = cameraBlock();
486
+ var anchor = anchorFor(facts);
487
+ if (anchor) record.anchor = anchor;
488
+ return record;
246
489
  }
247
490
 
248
491
  // ── Mouse ──
249
- // mousemove throttled to mouseHz; down/up/click are low-frequency and
250
- // carry adjudication weight, so they always record — with anchors.
492
+ // mouse.move is throttled to mouseHz; down/up/click are low-frequency and
493
+ // carry adjudication weight, so they always record — with alignment blocks.
251
494
  // All pointer listeners are CAPTURE-PHASE: page handlers may remove the
252
- // target, stopPropagation, or stop the recording before bubble reaches
253
- // the document.
495
+ // target, stopPropagation, or stop the recording before bubble reaches the
496
+ // document.
254
497
  var minMoveGap = 1000 / (config.mouseHz || 30);
255
498
  var lastMove = -Infinity;
256
499
  rec.addListener(doc, 'mousemove', guard(rec, 'mouse', function (e) {
257
500
  var t = now();
258
501
  if (t - lastMove < minMoveGap) return;
259
502
  lastMove = t;
260
- rec.pushEvent('mousemove', withClient({
261
- x: Math.round(e.pageX), y: Math.round(e.pageY)
262
- }, e), t);
503
+ rec.pushRecord({ type: 'mouse.move', x: clientX(e), y: clientY(e) }, t);
263
504
  }), { passive: true, capture: true });
264
- ['mousedown', 'mouseup', 'click'].forEach(function (kind) {
265
- rec.addListener(doc, kind, guard(rec, 'mouse', function (e) {
266
- flushCameraNow();
267
- var payload = withCameraSnapshot(withClient({
268
- x: Math.round(e.pageX), y: Math.round(e.pageY)
269
- }, e));
270
- var anchor = anchorFor(e);
271
- if (anchor) payload.target = anchor;
272
- rec.pushEvent(kind, payload, now());
273
- }), { passive: true, capture: true });
274
- });
505
+ [['mousedown', 'mouse.down'], ['mouseup', 'mouse.up'], ['click', 'mouse.click']]
506
+ .forEach(function (pair) {
507
+ rec.addListener(doc, pair[0], guard(rec, 'mouse', function (e) {
508
+ flushCameraNow();
509
+ var facts = targetFacts(e);
510
+ var record = withAlignment({
511
+ type: pair[1],
512
+ x: clientX(e), y: clientY(e),
513
+ button: typeof e.button === 'number' ? e.button : 0,
514
+ target: facts.node
515
+ }, facts);
516
+ rec.pushRecord(noteShadowRetarget(record, facts), now());
517
+ }), { passive: true, capture: true });
518
+ });
275
519
 
276
520
  // ── Keys ──
277
- // keys:'off' attaches nothing. Password fields AND any field matching
278
- // redactSelector never record key identity (redacted flag only) — otherwise
279
- // a redacted field's text is reconstructable keystroke-by-keystroke, which
280
- // would defeat the same redactSelector the input-value capture already honors.
521
+ // keys:'off' attaches nothing. A redacted OR excluded target records the
522
+ // spec §5.2 redacted variant — type and time, nothing else — because a
523
+ // field's text is reconstructable keystroke-by-keystroke otherwise, which
524
+ // would defeat the same withholding the input-value channel honours.
525
+ //
526
+ // Alignment fields ride key.down only, and not on auto-repeats: spec §6's
527
+ // cost rule, taken up deliberately because CH's studies are typing-heavy and
528
+ // each block costs a layout read.
281
529
  if (config.keys !== 'off') {
282
- ['keydown', 'keyup'].forEach(function (kind) {
283
- rec.addListener(doc, kind, guard(rec, 'keys', function (e) {
284
- // SUBTREE semantics (same as anchors/DOM capture): typing into a
285
- // field INSIDE a redacted container must not leak key identity —
286
- // the direct-target check alone let descendants through.
287
- if (inRedactedSubtree(e.target, config.redactSelector)) {
288
- rec.pushEvent(kind, { redacted: true }, now());
530
+ [['keydown', 'key.down'], ['keyup', 'key.up']].forEach(function (pair) {
531
+ rec.addListener(doc, pair[0], guard(rec, 'keys', function (e) {
532
+ var facts = targetFacts(e);
533
+ if (facts.redacted || facts.excluded) {
534
+ rec.pushRecord(
535
+ noteShadowRetarget({ type: pair[1], redacted: true }, facts), now());
289
536
  return;
290
537
  }
291
- rec.pushEvent(kind, { key: e.key, code: e.code }, now());
538
+ var aligned = pair[1] === 'key.down' && !e.repeat;
539
+ if (aligned) flushCameraNow();
540
+ var record = {
541
+ type: pair[1],
542
+ key: strOrEmpty(e.key), code: strOrEmpty(e.code),
543
+ mods: {
544
+ ctrl: !!e.ctrlKey, shift: !!e.shiftKey,
545
+ alt: !!e.altKey, meta: !!e.metaKey
546
+ },
547
+ repeat: !!e.repeat,
548
+ target: facts.node
549
+ };
550
+ if (aligned) withAlignment(record, facts);
551
+ rec.pushRecord(noteShadowRetarget(record, facts), now());
292
552
  }), true); // capture phase — before frameworks can stopPropagation
293
553
  });
294
554
  }
295
555
 
296
- // ── Clipboard ──
297
- // Lengths only. Content stays out of the replay stream by design; CH's own
298
- // pasteDropContent flag governs content capture in the integrity report.
556
+ // ── Clipboard (spec §5.3) ──
557
+ // CH's default is LENGTH-ONLY: pasted text routinely contains identifying
558
+ // material from outside the page, so the content stays out of the replay
559
+ // stream by design (CH's own pasteDropContent flag governs content capture in
560
+ // the integrity report, separately). `clipboardContent: true` selects the
561
+ // content mode the spec also defines.
562
+ //
563
+ // copy/cut carry no length: the clipboard is not populated yet when they
564
+ // fire, so any number read there would be a fiction about the selection.
565
+ function readClipboard(getData) {
566
+ var text = null, html = null;
567
+ try { text = getData('text'); } catch (err) { /* stays null */ }
568
+ if (config.clipboardContent) {
569
+ try { html = getData('text/html') || null; } catch (err) { /* stays null */ }
570
+ }
571
+ return { text: typeof text === 'string' ? text : null, html: html };
572
+ }
573
+
574
+ function clipboardRecord(type, facts, read) {
575
+ var record = {
576
+ type: type,
577
+ target: facts.node,
578
+ text: null, html: null,
579
+ len: read && read.text != null ? read.text.length : null
580
+ };
581
+ // Redacted target: content withheld, length allowed (spec §5.3, which
582
+ // permits `len` under the marker). An EXCLUDED target loses the length too:
583
+ // this module's floor #2 withholds an excluded element's state whole, and
584
+ // `input.value` already refuses even `value_len` for that same element, so
585
+ // a surviving clipboard length would be the one number that escaped.
586
+ if (facts.redacted) record.redacted = true;
587
+ if (facts.excluded) record.len = null;
588
+ if (config.clipboardContent && !facts.redacted && !facts.excluded && read) {
589
+ record.text = read.text;
590
+ record.html = read.html;
591
+ record.len = null;
592
+ }
593
+ return noteShadowRetarget(record, facts);
594
+ }
595
+
299
596
  rec.addListener(doc, 'paste', guard(rec, 'clipboard', function (e) {
300
597
  flushCameraNow();
301
- var len = null;
302
- try { len = e.clipboardData.getData('text').length; } catch (err) { /* len stays null */ }
303
- rec.pushEvent('paste', { len: len }, now());
598
+ var facts = targetFacts(e);
599
+ var data = e.clipboardData;
600
+ var read = readClipboard(function (fmt) { return data.getData(fmt); });
601
+ rec.pushRecord(clipboardRecord('clipboard.paste', facts, read), now());
304
602
  }), true);
305
- rec.addListener(doc, 'copy', guard(rec, 'clipboard', function () {
603
+ rec.addListener(doc, 'copy', guard(rec, 'clipboard', function (e) {
306
604
  flushCameraNow();
307
- rec.pushEvent('copy', {}, now());
605
+ rec.pushRecord(clipboardRecord('clipboard.copy', targetFacts(e), null), now());
308
606
  }), true);
309
- rec.addListener(doc, 'cut', guard(rec, 'clipboard', function () {
607
+ rec.addListener(doc, 'cut', guard(rec, 'clipboard', function (e) {
310
608
  flushCameraNow();
311
- rec.pushEvent('cut', {}, now());
609
+ rec.pushRecord(clipboardRecord('clipboard.cut', targetFacts(e), null), now());
312
610
  }), true);
313
611
  rec.addListener(doc, 'drop', guard(rec, 'clipboard', function (e) {
314
612
  flushCameraNow();
315
- var len = null;
316
- try { len = e.dataTransfer.getData('text').length; } catch (err) { /* len stays null */ }
317
- rec.pushEvent('ch:drop', { len: len }, now());
613
+ var facts = targetFacts(e);
614
+ var data = e.dataTransfer;
615
+ var read = readClipboard(function (fmt) { return data.getData(fmt); });
616
+ rec.pushRecord(clipboardRecord('clipboard.drop', facts, read), now());
318
617
  }), true);
319
618
 
320
619
  // ── Input values (RAF-coalesced per target) ──
321
- // Rapid typing produces one event per frame per field, holding the LAST
322
- // value — enough to reconstruct field state on scrub without recording
323
- // every intermediate keystroke twice (keydown already carries timing).
620
+ // Rapid typing produces one event per frame per field, holding the LAST value
621
+ // — enough to reconstruct field state on scrub without recording every
622
+ // intermediate keystroke twice (key.down already carries timing).
623
+ //
624
+ // v2 splits v1's single `input` event into the three types spec §5.2 defines,
625
+ // by what the control's state IS: a select's selection, a box's checkedness,
626
+ // everything else's value. The same three shapes initial-state.js seeds.
324
627
  var pendingInputs = new Map(); // target → latest event time
325
628
  var inputFlushQueued = false;
629
+
630
+ function readValue(target) {
631
+ if (target.value != null) return String(target.value);
632
+ if (target.isContentEditable && target.textContent != null) return String(target.textContent);
633
+ return '';
634
+ }
635
+
636
+ function selectedValues(target) {
637
+ var out = [];
638
+ var options = target.options || [];
639
+ for (var i = 0; i < options.length; i++) {
640
+ if (options[i].selected) {
641
+ out.push(typeof options[i].value === 'string'
642
+ ? options[i].value : String(options[i].textContent || ''));
643
+ }
644
+ }
645
+ return out;
646
+ }
647
+
648
+ // `<input type="file">`, read the way the password floor reads its own type
649
+ // (redaction.js): the IDL property first, the attribute as the fallback that
650
+ // catches duck-typed nodes and unknown-type reads.
651
+ function isFileInput(el) {
652
+ if (!el || el.tagName !== 'INPUT') return false;
653
+ if (String(el.type || '').toLowerCase() === 'file') return true;
654
+ var attr = typeof el.getAttribute === 'function' ? el.getAttribute('type') : null;
655
+ return typeof attr === 'string' && attr.toLowerCase() === 'file';
656
+ }
657
+
658
+ // `composed` is the shadow-internal target captured at DISPATCH time, since
659
+ // composedPath() is only meaningful during dispatch and this channel reads
660
+ // its value a frame later.
661
+ function inputRecordFor(target, composed) {
662
+ // Spec §4: nothing about an excluded element's state, not even a length.
663
+ var f = floorsFor(target, true);
664
+ if (f.excluded) return null;
665
+ var c = composed ? floorsFor(composed, false) : null;
666
+ if (c && c.excluded) return null;
667
+ var node = f.node;
668
+ if (node == null) return null; // spec §5.2 requires a node id
669
+ var redacted = f.redacted || !!(c && c.redacted);
670
+ // FILE INPUTS SAY NOTHING (spec §13 puts file selection outside the format,
671
+ // and initial-state.js's SKIP_INPUT_TYPES already skips them at the seed).
672
+ // A file input's `value` is not participant-typed text, it is
673
+ // "C:\fakepath\<the participant's own filename>" — real PII from outside
674
+ // the page — so neither the value NOR its length may travel. Dropping the
675
+ // event, rather than emitting the redacted variant, is what keeps this
676
+ // channel and the keyframe seed saying the same thing about one control.
677
+ // happy-dom never synthesizes the fakepath value, which is why the leak
678
+ // survived unit coverage until the Chromium battery
679
+ // (tests/browser/replay/capture-chromium.battery.mjs, case 14) drove a real
680
+ // setInputFiles.
681
+ //
682
+ // Asked of the COMPOSED target too, like the two floors above it: at a
683
+ // document-level listener a file input inside an open shadow root arrives
684
+ // retargeted to its host, and a host that proxies `.value` would otherwise
685
+ // take the leaky path. Property OR attribute, like the password floor
686
+ // (redaction.js): the IDL `type` is the browser's answer but is absent on
687
+ // duck-typed nodes and reads "text" for an unknown type.
688
+ if (isFileInput(target) || (composed && isFileInput(composed))) return null;
689
+ var type = String(target.type || '').toLowerCase();
690
+ if (target.tagName === 'SELECT') {
691
+ // No redacted variant exists for input.select or input.checked (spec
692
+ // §5.2 defines one for input.value only), so a withheld control emits
693
+ // nothing at all rather than a shape the format does not have.
694
+ return redacted ? null
695
+ : { type: 'input.select', node: node, values: selectedValues(target) };
696
+ }
697
+ if (target.tagName === 'INPUT' && (type === 'checkbox' || type === 'radio')) {
698
+ // checked is an element PROPERTY (never an attribute mutation), so the
699
+ // DOM replay can only restore box state from here — but a redacted
700
+ // field's checked state is still its value: withhold it.
701
+ return redacted ? null
702
+ : { type: 'input.checked', node: node, checked: !!target.checked };
703
+ }
704
+ var value = readValue(target);
705
+ if (redacted) {
706
+ return { type: 'input.value', node: node, redacted: true, value_len: value.length };
707
+ }
708
+ return { type: 'input.value', node: node, value: value };
709
+ }
710
+
326
711
  function flushInputs() {
327
712
  inputFlushQueued = false;
328
- pendingInputs.forEach(function (t, target) {
329
- // el is a display descriptor; id is the RAW id for the viewer to
330
- // resolve via getElementById (ids like "a:b c" are not selector-safe).
331
- var payload = { el: describeEl(target), id: target.id || null };
332
- var value = target.value != null ? String(target.value)
333
- : (target.isContentEditable && target.textContent != null
334
- ? String(target.textContent) : '');
335
- // SUBTREE semantics: a field inside a redacted container leaks no
336
- // value. The id/el descriptor stays (the DOM snapshot already carries
337
- // redacted elements' ids; the viewer needs a target for the bullets).
338
- if (inRedactedSubtree(target, config.redactSelector)) {
339
- payload.redacted = true;
340
- payload.value_len = value.length;
341
- } else {
342
- payload.value = value;
343
- // Marker ref for duplicate-id-safe restore (non-redacted only:
344
- // redacted events gain no NEW identity fields).
345
- var inputMarkers = typeof rec.getMarkers === 'function' ? rec.getMarkers() : null;
346
- if (inputMarkers) payload.n = inputMarkers.refFor(target);
713
+ pendingInputs.forEach(function (pending, target) {
714
+ var record = inputRecordFor(target, pending.composed);
715
+ if (record) {
716
+ if (pending.composed) withVendorExtension(record, { shadow_retarget: true });
717
+ rec.pushRecord(record, pending.t);
347
718
  }
348
- // checked is an element PROPERTY (never an attribute mutation), so
349
- // the DOM replay can only restore checkbox/radio state from here —
350
- // but a redacted field's checked state is still its VALUE: withhold it.
351
- if (!payload.redacted && (target.type === 'checkbox' || target.type === 'radio')) {
352
- payload.checked = !!target.checked;
353
- }
354
- rec.pushEvent('input', payload, t);
355
719
  });
356
720
  pendingInputs.clear();
357
721
  }
358
722
  rec.addListener(doc, 'input', guard(rec, 'input', function (e) {
359
723
  if (!e.target) return;
360
- pendingInputs.set(e.target, now());
724
+ pendingInputs.set(e.target, { t: now(), composed: composedTarget(e) });
361
725
  if (!inputFlushQueued) {
362
726
  inputFlushQueued = true;
363
727
  raf(guard(rec, 'input', flushInputs));
364
728
  }
365
729
  }), true);
366
730
 
731
+ // ── Scrolled-element tracker (spec §3 initial_state.element_scroll) ──
732
+ // Which inner scrollers a keyframe has to seed is not answerable from the
733
+ // DOM: `overflow` says an element COULD scroll, not that it has, and the
734
+ // participant's offsets are the state a fresh snapshot cannot carry. The one
735
+ // place that knows is the scroll listener below, so it keeps the set.
736
+ //
737
+ // Lifetime is the RECORDING, not the trial: an element scrolled during trial
738
+ // 2 still needs seeding at the keyframe of trial 7, so the Set lives in this
739
+ // attachment (created once, at startSession) rather than on a trial.
740
+ //
741
+ // A strong Set, because the seed has to ENUMERATE it — which makes it a
742
+ // retention leak by construction, one detached subtree per scrolled element
743
+ // per trial over a long session. Pruning on read and once per trial bounds it
744
+ // without a reaper: `isConnected === false` is the browser's own answer, and
745
+ // reading it explicitly (rather than falsy) keeps duck-typed test nodes,
746
+ // which claim nothing, in the Set.
747
+ //
748
+ // Deliberately dumb: every element that scrolls goes in, redacted or excluded
749
+ // or outside the observed root. What may be SEEDED is one question with one
750
+ // answer, and it lives in initial-state.js.
751
+ var scrolledElements = new Set();
752
+ function pruneScrolledElements() {
753
+ scrolledElements.forEach(function (el) {
754
+ if (el && el.isConnected === false) scrolledElements.delete(el);
755
+ });
756
+ }
757
+
367
758
  // ── Scroll (RAF-coalesced, per target, original timestamps) ──
368
759
  rec.addListener(win, 'scroll', guard(rec, 'scroll', function (e) {
369
760
  var target = e && e.target && e.target.tagName ? e.target : null;
370
761
  if (target) {
371
762
  pendingElementScrolls.set(target, { t: now() });
763
+ scrolledElements.add(target);
372
764
  } else {
373
765
  pendingWindowScroll = { t: now() };
374
766
  }
@@ -379,53 +771,68 @@ export function attachTraceCapture(rec, env) {
379
771
  }), { passive: true, capture: true });
380
772
 
381
773
  // ── Touch ──
774
+ // Spec §5.2 records the whole touch list, not one point: multi-touch is what
775
+ // distinguishes a pinch from a drag. touch.end reports `changedTouches` (the
776
+ // points that ended) — `touches` at that moment holds the fingers still down,
777
+ // which says nothing about the gesture that just finished.
778
+ function touchList(list) {
779
+ var out = [];
780
+ for (var i = 0; i < (list ? list.length : 0); i++) {
781
+ var p = list[i];
782
+ out.push({
783
+ id: typeof p.identifier === 'number' ? p.identifier : i,
784
+ x: clientX(p), y: clientY(p)
785
+ });
786
+ }
787
+ return out;
788
+ }
789
+
382
790
  rec.addListener(doc, 'touchstart', guard(rec, 'touch', function (e) {
383
791
  flushCameraNow();
384
- var p = e.touches && e.touches[0];
385
- if (!p) return;
386
- var payload = withCameraSnapshot(
387
- withClient({ x: Math.round(p.pageX), y: Math.round(p.pageY) }, p));
388
- var anchor = anchorFor(e);
389
- if (anchor) payload.target = anchor;
390
- rec.pushEvent('touchstart', payload, now());
792
+ var facts = targetFacts(e);
793
+ var record = withAlignment(
794
+ { type: 'touch.start', touches: touchList(e.touches) }, facts);
795
+ rec.pushRecord(noteShadowRetarget(record, facts), now());
391
796
  }), { passive: true, capture: true });
392
797
  var touchPending = false;
393
- var lastTouch = null;
798
+ var lastTouches = null;
394
799
  var lastTouchT = 0;
395
800
  rec.addListener(doc, 'touchmove', guard(rec, 'touch', function (e) {
396
- lastTouch = e.touches && e.touches[0];
801
+ // Converted eagerly: a Touch is only meaningful for its own event, so the
802
+ // coalescing window must hold the numbers, not the objects.
803
+ lastTouches = touchList(e.touches);
397
804
  lastTouchT = now();
398
805
  if (touchPending) return;
399
806
  touchPending = true;
400
807
  raf(guard(rec, 'touch', function () {
401
808
  touchPending = false;
402
- if (lastTouch) {
403
- rec.pushEvent('touchmove', withClient({
404
- x: Math.round(lastTouch.pageX), y: Math.round(lastTouch.pageY)
405
- }, lastTouch), lastTouchT);
809
+ if (lastTouches) {
810
+ rec.pushRecord({ type: 'touch.move', touches: lastTouches }, lastTouchT);
406
811
  }
407
812
  }));
408
813
  }), { passive: true, capture: true });
409
814
  rec.addListener(doc, 'touchend', guard(rec, 'touch', function (e) {
410
815
  flushCameraNow();
411
- var p = e.changedTouches && e.changedTouches[0];
412
- if (!p) return;
413
- var payload = withCameraSnapshot(
414
- withClient({ x: Math.round(p.pageX), y: Math.round(p.pageY) }, p));
415
- var anchor = anchorFor(e);
416
- if (anchor) payload.target = anchor;
417
- rec.pushEvent('touchend', payload, now());
816
+ var facts = targetFacts(e);
817
+ var record = withAlignment(
818
+ { type: 'touch.end', touches: touchList(e.changedTouches) }, facts);
819
+ rec.pushRecord(noteShadowRetarget(record, facts), now());
418
820
  }), { passive: true, capture: true });
419
821
 
420
822
  // ── Focus / visibility ──
823
+ // visibility.* records Page Visibility transitions (spec §5.5) — tab-away,
824
+ // distinct from window blur, and now distinct on the wire too: v1 folded both
825
+ // directions into one `visibility` event with a boolean.
421
826
  rec.addListener(win, 'blur', guard(rec, 'focus', function () {
422
- rec.pushEvent('blur', {}, now());
827
+ rec.pushRecord({ type: 'blur' }, now());
423
828
  }));
424
829
  rec.addListener(win, 'focus', guard(rec, 'focus', function () {
425
- rec.pushEvent('focus', {}, now());
830
+ rec.pushRecord({ type: 'focus' }, now());
426
831
  }));
427
832
  rec.addListener(doc, 'visibilitychange', guard(rec, 'focus', function () {
428
- rec.pushEvent('visibility', { hidden: !!doc.hidden }, now());
833
+ rec.pushRecord({
834
+ type: doc.hidden ? 'visibility.hidden' : 'visibility.visible'
835
+ }, now());
429
836
  }));
430
837
 
431
838
  // ── Viewport (RAF-coalesced resize; event-driven, no polling) ──
@@ -433,36 +840,46 @@ export function attachTraceCapture(rec, env) {
433
840
  pendingResize = { t: now() };
434
841
  if (!resizeFlushQueued) {
435
842
  resizeFlushQueued = true;
436
- raf(guard(rec, 'viewport', flushResize));
843
+ raf(guard(rec, 'viewport', flushViewport));
437
844
  }
438
845
  }));
439
846
  // visualViewport: size/scale AND pan (scroll). Pinch-pan does not move the
440
- // pointer relative to the DOM (page/client coords are layout-viewport CSS
441
- // px), so vv is visible-region METADATA, not part of the cursor projection
442
- // — recorded so the viewer can later show what the participant could see.
847
+ // pointer relative to the DOM (client coords are layout-viewport CSS px), so
848
+ // vv is visible-region METADATA, not part of the cursor projection — recorded
849
+ // so the viewer can later show what the participant could see.
443
850
  if (win.visualViewport && typeof win.visualViewport.addEventListener === 'function') {
444
851
  ['resize', 'scroll'].forEach(function (kind) {
445
852
  rec.addListener(win.visualViewport, kind, guard(rec, 'viewport', function () {
446
853
  pendingVv = { t: now() };
447
854
  if (!vvFlushQueued) {
448
855
  vvFlushQueued = true;
449
- raf(guard(rec, 'viewport', flushVv));
856
+ raf(guard(rec, 'viewport', flushViewport));
450
857
  }
451
858
  }));
452
859
  });
453
860
  }
454
861
 
455
- // ── Per-trial camera seed ──
456
- // Scroll + viewport state at trial start. Without this, a trial that
457
- // begins mid-scroll (the user's fixture, trial 2) replays from a wrong
458
- // camera until its first in-trial scroll/resize event.
459
- rec.onTrialStart(function (trial) {
460
- var dims = clientDims();
461
- trial.viewState = {
462
- x: Math.round(win.scrollX || 0), y: Math.round(win.scrollY || 0),
463
- w: win.innerWidth || null, h: win.innerHeight || null,
464
- cw: dims.cw, ch: dims.ch,
465
- dpr: win.devicePixelRatio || 1
466
- };
862
+ // ── Per-trial upkeep ──
863
+ // v1 stamped `trial.viewState` here — a window-scroll + viewport seed, added
864
+ // for a real mid-scroll alignment bug. Spec §14 names `initial_state` its v2
865
+ // successor, and that seed is taken at the KEYFRAME (initial-state.js), after
866
+ // the snapshot walk that gives it node ids to name. So this hook keeps only
867
+ // the tracker upkeep the seed depends on; the call site is the capture
868
+ // wiring's (Task 6).
869
+ rec.onTrialStart(function () {
870
+ pruneScrolledElements();
467
871
  });
872
+
873
+ // The capture handle. v1 callers ignore the return value (index.js calls this
874
+ // for its side effects), so it stays invisible to them; the keyframe path
875
+ // reads the scrolled-element set through it. The span is NOT echoed back: it
876
+ // comes IN through `env`, so its owner already has it.
877
+ return {
878
+ // The LIVE Set, pruned of detached elements first. Read-only by contract:
879
+ // buildInitialState iterates it and the tracker owns its contents.
880
+ getScrolledElements: function () {
881
+ pruneScrolledElements();
882
+ return scrolledElements;
883
+ },
884
+ };
468
885
  }