cyborg-hunter 0.7.0 → 0.7.3

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.
@@ -7,130 +7,20 @@
7
7
  // tags load fine. The report injects the script lazily when the analyst
8
8
  // opens a participant's Replay section.
9
9
  //
10
- // This module is also the wire→viewer time conversion (the second of the
11
- // two allowed conversion points): SessionRecording carries ms since
12
- // session start; the viewer speaks trial-relative ms.
10
+ // The wire→viewer time conversion (the second of the two allowed conversion
11
+ // points) lives in replay/viewer-model.js (0.7.2 extraction, pure/no Node
12
+ // APIs so a browser demo can bundle it); this module re-exports it below.
13
13
 
14
14
  import { writeFileSync, mkdirSync } from 'fs';
15
15
  import { join } from 'path';
16
16
 
17
17
  import { sanitizeId as sanitize } from '../../shared/constants.js';
18
+ import { buildViewerModel } from '../../replay/viewer-model.js';
18
19
 
19
- /**
20
- * Wire SessionRecording → viewer model. Times become trial-relative
21
- * (t − t_load); null anchors (standalone implicit trials) degrade to the
22
- * first event's time so durations are always finite.
23
- */
24
- export function buildViewerModel(recording) {
25
- const md = recording.metadata || {};
26
- const ext = recording.ch_extensions || {};
27
-
28
- // ── Camera seeding (central, per Sol round-1 finding 8) ──
29
- // New recordings carry a per-trial view_state seed. Legacy recordings
30
- // don't — but the full event stream is present, so each trial's starting
31
- // camera is reconstructed by folding all PRIOR trials' window-scroll and
32
- // resize events over the session-start viewport. Initial scroll is assumed
33
- // 0 (a recording that starts pre-scrolled with no scroll events is
34
- // unrecoverable — that's what the viewer's legacy banner covers).
35
- const vp = recording.viewport || {};
36
- const vv = vp.visual_viewport || {};
37
- // Scrollbar delta: legacy resize events carry only innerWidth/Height; the
38
- // layout (client) width is estimated as w minus the session-start delta
39
- // between innerWidth and the layout width (visual_viewport.width).
40
- const sbW = (vp.width && (vp.client_width || vv.width))
41
- ? vp.width - (vp.client_width || vv.width) : 0;
42
- const sbH = (vp.height && (vp.client_height || vv.height))
43
- ? vp.height - (vp.client_height || vv.height) : 0;
44
- let foldState = {
45
- x: 0, y: 0,
46
- w: vp.width || null, h: vp.height || null,
47
- cw: vp.client_width || vv.width || vp.width || null,
48
- ch: vp.client_height || vv.height || vp.height || null,
49
- dpr: vp.dpr || 1
50
- };
51
- const foldEvent = (state, e) => {
52
- if (e.kind === 'scroll' && e.el == null && e.redacted == null) {
53
- state.x = Number(e.x) || 0;
54
- state.y = Number(e.y) || 0;
55
- } else if (e.kind === 'resize') {
56
- state.w = e.w != null ? e.w : state.w;
57
- state.h = e.h != null ? e.h : state.h;
58
- state.cw = e.cw != null ? e.cw : (e.w != null ? e.w - sbW : state.cw);
59
- state.ch = e.ch != null ? e.ch : (e.h != null ? e.h - sbH : state.ch);
60
- if (e.dpr != null) state.dpr = e.dpr;
61
- }
62
- return state;
63
- };
64
-
65
- // Defensive against malformed/truncated artifacts (a hand-edited or
66
- // partially-written recording): non-array trials/events and null entries must
67
- // degrade to empty rather than throw and abort the whole cohort report.
68
- const rawTrials = Array.isArray(recording.trials) ? recording.trials : [];
69
- const trials = rawTrials.map((trial) => {
70
- trial = trial || {};
71
- // Sort by absolute time before anchoring. RAF-coalesced input events flush
72
- // with an EARLIER timestamp than events pushed after they were enqueued, so
73
- // the recorded array is not strictly time-ordered; the viewer scrubs by
74
- // scanning until the first future event and would otherwise mis-apply an
75
- // out-of-order event on a seek. Stable sort keeps equal-time order.
76
- const events = (Array.isArray(trial.events) ? trial.events : [])
77
- .filter((e) => e && typeof e === 'object')
78
- .slice()
79
- .sort((a, b) => (Number(a.t) || 0) - (Number(b.t) || 0));
80
- const anchor = trial.t_load != null ? trial.t_load
81
- : (events.length > 0 ? events[0].t : 0);
82
- const lastT = events.length > 0 ? events[events.length - 1].t : anchor;
83
- const end = trial.t_end != null ? trial.t_end : lastT;
84
- // Camera seed for THIS trial: recorded view_state, else the folded state
85
- // as of the end of the previous trial. A recorded view_state also
86
- // RESYNCS the fold — in a mixed recording (some trials seeded, some
87
- // not: truncation, version mixes) a later unseeded trial must inherit
88
- // real observed state, not a fold that ignored every observation.
89
- if (trial.view_state) foldState = Object.assign({}, foldState, trial.view_state);
90
- const camera = trial.view_state
91
- ? Object.assign({}, trial.view_state, { source: 'view_state' })
92
- : Object.assign({}, foldState, { source: 'folded' });
93
- // Advance the fold across this trial's events for the NEXT trial's seed.
94
- events.forEach((e) => foldEvent(foldState, e));
95
- return {
96
- index: trial.trial_index,
97
- id: trial.trial_id,
98
- plugin: trial.plugin,
99
- durMs: Math.max(0, Math.round((end - anchor) * 10) / 10),
100
- initialDom: trial.initial_dom || '',
101
- camera,
102
- events: events.map((e) => {
103
- const out = Object.assign({}, e);
104
- out.t = Math.max(0, Math.round(((Number(e.t) || 0) - anchor) * 10) / 10);
105
- return out;
106
- })
107
- };
108
- });
109
-
110
- return {
111
- pid: md.participant_id != null ? String(md.participant_id) : 'unknown',
112
- tier: md.tier || 'trace',
113
- keys: md.keys || null,
114
- startTime: md.start_time || null,
115
- endReason: md.end_reason || null,
116
- recorder: md.recorder || null,
117
- viewport: recording.viewport || null,
118
- // Legacy = ANY trial lacks a view_state seed (all-legacy recordings and
119
- // mixed/truncated ones alike): those trials replay on folded camera
120
- // state, so the reduced-guarantees banner must show.
121
- legacy: !rawTrials.every((t) => t && t.view_state),
122
- markerAttr: ext.marker_attr || null,
123
- // Session scrollbar delta (innerWidth − layout width): the viewer uses
124
- // the same fallback chain as the folding above for legacy resize events.
125
- scrollbar: { w: sbW, h: sbH },
126
- stylesheets: (recording.stylesheets && recording.stylesheets.initial) || [],
127
- scoring: ext.scoring || null,
128
- guardViolations: ext.guard_violations || [],
129
- captureStopped: !!ext.capture_stopped,
130
- captureFailures: (ext.capture_failures || []).map((f) => f.channel),
131
- trials
132
- };
133
- }
20
+ // Load-bearing re-export: renderReplayAssets (below) calls buildViewerModel
21
+ // in-file, and tests/cli/replay-render.test.js + tests/replay/alignment-viewer-model.test.js
22
+ // import it from here.
23
+ export { buildViewerModel };
134
24
 
135
25
  /**
136
26
  * Writes replay/<sanitizedPid>.replay.js for every participant with an
@@ -48,6 +48,17 @@
48
48
  var TOL_CONTAIN = 2;
49
49
  var TOL_STAGE = 2;
50
50
 
51
+ // Keycast: how long a chip keeps fading after its keyup, in trial-relative
52
+ // ms. Purely a function of playhead (like the click-ripple fade in
53
+ // drawOverlay below), not real time, so seeking lands on the same visual
54
+ // state playing to it would.
55
+ var KEYCAST_FADE_MS = 500;
56
+
57
+ // Buffer-cap explanation fallback: recorder.js's REPLAY_DEFAULTS, used
58
+ // only when a stopped trial's own ch:capture_stopped event (the real
59
+ // configured value for THIS recording) can't be found.
60
+ var CAP_DEFAULTS = { events: 50000, chars: 8000000 };
61
+
51
62
  var DISCRETE_KINDS = {
52
63
  mousedown: true, mouseup: true, click: true, touchstart: true, touchend: true
53
64
  };
@@ -66,6 +77,17 @@
66
77
  return m + ':' + (rem < 10 ? '0' : '') + rem;
67
78
  }
68
79
 
80
+ // Human label for a keycast chip. e.key already names special keys well
81
+ // (Enter, Shift, Backspace, ArrowLeft…); the one gap is the space bar,
82
+ // which arrives as a literal ' ' and would render as an invisible chip.
83
+ function formatKeyLabel(key) {
84
+ return key === ' ' ? 'Space' : key;
85
+ }
86
+
87
+ function fmtCount(n) {
88
+ return n.toLocaleString('en-US');
89
+ }
90
+
69
91
  // ── DOM reconstruction (tier 'dom') ──
70
92
  // The recorded DOM is UNTRUSTED (an adversarial participant or extension
71
93
  // may have injected markup). sandbox="" already blocks scripts; the CSP
@@ -144,6 +166,12 @@
144
166
  var playhead = 0; // trial-relative ms
145
167
  var playing = false;
146
168
  var speed = 1;
169
+ // Continuous whole-session playback, default ON: at a trial's end while
170
+ // playing, load the next trial and keep going, so an analyst watches a
171
+ // full session as one video instead of stopping at every boundary. The
172
+ // 'pause at trial boundaries' toggle flips this off, restoring the
173
+ // original per-trial-stop behavior.
174
+ var autoAdvance = true;
147
175
  var appliedIdx = 0; // events applied so far (in trial event order)
148
176
  var iframeGen = 0; // increments per rebuild; stale onloads bail
149
177
  var domReady = false;
@@ -186,18 +214,64 @@
186
214
  var o = el('option', null, s + '×'); o.value = String(s); speedSel.appendChild(o);
187
215
  });
188
216
  var clock = el('span', 'replay-clock', '0:00.0 / 0:00.0');
217
+ var sessionPos = el('span', 'replay-note replay-session-pos', '');
218
+ var pauseLabel = el('label', 'replay-pause-toggle');
219
+ var pauseCheckbox = document.createElement('input');
220
+ pauseCheckbox.type = 'checkbox';
221
+ pauseCheckbox.className = 'replay-pause-checkbox';
222
+ pauseCheckbox.checked = false; // default: continuous playback ON
223
+ pauseCheckbox.addEventListener('change', function () {
224
+ autoAdvance = !pauseCheckbox.checked;
225
+ });
226
+ pauseLabel.appendChild(pauseCheckbox);
227
+ pauseLabel.appendChild(document.createTextNode(' Pause at trial boundaries'));
189
228
  header.appendChild(badge);
190
229
  header.appendChild(trialSel);
191
230
  header.appendChild(playBtn);
192
231
  header.appendChild(speedSel);
193
232
  header.appendChild(clock);
233
+ header.appendChild(sessionPos);
234
+ if (trials.length > 1) header.appendChild(pauseLabel);
194
235
  if (!model.scoring) {
195
236
  header.appendChild(el('span', 'replay-note',
196
- 'Integrity scoring not available (standalone recording)'));
237
+ 'No integrity score attached (this recording was captured standalone)'));
197
238
  }
198
239
  if (model.captureStopped) {
199
- header.appendChild(el('span', 'replay-note replay-warn',
200
- 'Capture stopped early (buffer cap)'));
240
+ // The real configured caps for THIS recording live on the trial's own
241
+ // ch:capture_stopped marker (recorder.js writes it with the limit that
242
+ // was actually crossed) — read those instead of assuming the library
243
+ // defaults, since maxEventsPerTrial/maxCharsPerTrial are configurable.
244
+ var capEventsLimit = null, capCharsLimit = null, capTrialsHit = 0;
245
+ trials.forEach(function (t) {
246
+ var hit = false;
247
+ (t.events || []).forEach(function (e) {
248
+ if (e.kind !== 'ch:capture_stopped') return;
249
+ hit = true;
250
+ if (e.limit != null && capEventsLimit == null) capEventsLimit = e.limit;
251
+ if (e.limit_chars != null && capCharsLimit == null) capCharsLimit = e.limit_chars;
252
+ });
253
+ if (hit) capTrialsHit++;
254
+ });
255
+ if (capEventsLimit == null) capEventsLimit = CAP_DEFAULTS.events;
256
+ if (capCharsLimit == null) capCharsLimit = CAP_DEFAULTS.chars;
257
+ var capTrialsCount = capTrialsHit || 1;
258
+ var capDetails = document.createElement('details');
259
+ capDetails.className = 'replay-note replay-warn replay-cap';
260
+ capDetails.setAttribute('data-ch-cap-note', '');
261
+ var capSummary = document.createElement('summary');
262
+ capSummary.textContent = 'Capture stopped early in ' + capTrialsCount +
263
+ ' trial' + (capTrialsCount === 1 ? '' : 's') + ' (buffer cap, details)';
264
+ var capBody = el('p', null,
265
+ 'The recorder caps each trial at ' + fmtCount(capEventsLimit) + ' events or about ' +
266
+ fmtCount(capCharsLimit) + ' characters, whichever comes first (the character count ' +
267
+ 'starts from that trial’s initial DOM snapshot). Once a trial crosses either cap, ' +
268
+ 'that trial stops recording and a marker is written; the next trial starts capturing ' +
269
+ 'fresh. The caps limit the damage one runaway trial can do. They do not bound the whole ' +
270
+ 'session: a recording with many trials can still add up to a large file. Both caps are ' +
271
+ 'configurable when the recorder is attached (maxEventsPerTrial, maxCharsPerTrial).');
272
+ capDetails.appendChild(capSummary);
273
+ capDetails.appendChild(capBody);
274
+ header.appendChild(capDetails);
201
275
  }
202
276
  // Alignment status chips — the "never fail silently" surface.
203
277
  var alignChip = el('span', 'replay-note', '');
@@ -206,17 +280,17 @@
206
280
  header.appendChild(alignChip);
207
281
  if (model.legacy) {
208
282
  var legacyChip = el('span', 'replay-note replay-warn',
209
- 'Legacy recording — reduced alignment guarantees (re-projected coordinates, unverified)');
283
+ 'Legacy recording: alignment guarantees are reduced (coordinates re-projected, unverified)');
210
284
  legacyChip.setAttribute('data-ch-legacy', '');
211
285
  header.appendChild(legacyChip);
212
286
  }
213
287
  var iframeChip = el('span', 'replay-note replay-warn',
214
- 'Contains iframe content that was NOT captured — interactions inside it are invisible');
288
+ 'This recording contains iframe content that was not captured. Interactions inside it are not visible here.');
215
289
  iframeChip.setAttribute('data-ch-iframe-warn', '');
216
290
  iframeChip.style.display = 'none';
217
291
  header.appendChild(iframeChip);
218
292
  var shadowChip = el('span', 'replay-note replay-warn',
219
- 'Contains shadow-DOM content that was NOT captured — those areas replay hollow');
293
+ 'This recording contains shadow DOM content that was not captured. Those areas replay empty.');
220
294
  shadowChip.setAttribute('data-ch-shadow-warn', '');
221
295
  shadowChip.style.display = 'none';
222
296
  header.appendChild(shadowChip);
@@ -238,7 +312,7 @@
238
312
  return sh.css && /@keyframes/i.test(sh.css);
239
313
  });
240
314
  if (hasKeyframes) {
241
- animChip.textContent = 'Page uses CSS animations — they replay on viewer time; animated positions are approximate';
315
+ animChip.textContent = 'This page uses CSS animations. They replay on the viewer’s own clock, so animated positions are approximate.';
242
316
  animChip.style.display = '';
243
317
  }
244
318
  header.appendChild(animChip);
@@ -247,7 +321,7 @@
247
321
  var omittedSheets = (model.stylesheets || []).filter(function (sh) { return !sh.css; }).length;
248
322
  if (omittedSheets > 0) {
249
323
  var cssNote = el('span', 'replay-note',
250
- omittedSheets + ' external stylesheet(s) not loaded — layout may differ. ');
324
+ omittedSheets + ' external stylesheet(s) were not loaded. Layout may differ. ');
251
325
  var cssBtn = el('button', 'replay-css-btn', 'Load external CSS');
252
326
  cssBtn.title = 'Fetches the recorded stylesheet URLs from their origins (network requests leave this machine).';
253
327
  cssBtn.addEventListener('click', function () {
@@ -282,13 +356,21 @@
282
356
  } else {
283
357
  stage.classList.add('replay-neutral');
284
358
  stage.appendChild(el('div', 'replay-neutral-label',
285
- 'Trace-only recording — DOM not captured at this tier'));
359
+ 'Trace-only recording: DOM was not captured at this tier'));
286
360
  }
287
361
 
288
362
  var overlay = document.createElement('canvas');
289
363
  overlay.className = 'replay-overlay';
290
364
  overlay.setAttribute('aria-hidden', 'true');
291
365
  stage.appendChild(overlay);
366
+ // Keycast: chips for keys currently down or recently released, mirroring
367
+ // the click-ripple/cursor-trail convention above (a pure function of
368
+ // playhead, drawn alongside them — see drawKeycast). keys:'off'
369
+ // recordings carry no keydown/keyup events, so this stays empty; that
370
+ // absence IS the honest signal, no separate note needed.
371
+ var keycast = el('div', 'replay-keycast');
372
+ keycast.setAttribute('aria-hidden', 'true');
373
+ stage.appendChild(keycast);
292
374
  mount.appendChild(stage);
293
375
  var ctx = overlay.getContext('2d');
294
376
 
@@ -443,6 +525,7 @@
443
525
  applyCamScroll();
444
526
  applyDomUpTo(thenSeekTo);
445
527
  drawOverlay();
528
+ drawKeycast();
446
529
  updateStatusChips();
447
530
  // Late-layout revalidation: web fonts settling after the first
448
531
  // layout can shift geometry and stale an initially-passing check.
@@ -707,6 +790,7 @@
707
790
  applyDomUpTo(playhead);
708
791
  }
709
792
  drawOverlay();
793
+ drawKeycast();
710
794
  drawLane();
711
795
  drawTicker();
712
796
  updateStatusChips();
@@ -813,6 +897,66 @@
813
897
  }
814
898
  }
815
899
 
900
+ // ── Keycast (bottom-of-stage chips) ──
901
+ // Pairs keydown with its keyup to get each key's [down, up] window, then
902
+ // returns the ones "live" at T: down and not yet released, or released
903
+ // within KEYCAST_FADE_MS. Non-redacted pairs match by e.code (handles
904
+ // overlapping holds, e.g. Shift+A); redacted pairs carry no code (the
905
+ // identity itself is withheld), so they match FIFO against each other —
906
+ // an approximation that assumes redacted keystrokes don't overlap, true
907
+ // for the single-field-typing case this exists to cover.
908
+ function computeKeycastChips(T) {
909
+ var events = trial().events;
910
+ var openByCode = new Map();
911
+ var redactedQueue = [];
912
+ var tokens = [];
913
+ for (var i = 0; i < events.length; i++) {
914
+ var e = events[i];
915
+ if (e.t > T) break;
916
+ if (e.kind !== 'keydown' && e.kind !== 'keyup') continue;
917
+ if (e.redacted) {
918
+ if (e.kind === 'keydown') {
919
+ var rtok = { redacted: true, downT: e.t, upT: null };
920
+ redactedQueue.push(rtok);
921
+ tokens.push(rtok);
922
+ } else {
923
+ var rt = redactedQueue.shift();
924
+ if (rt) rt.upT = e.t;
925
+ }
926
+ continue;
927
+ }
928
+ if (e.kind === 'keydown') {
929
+ // Auto-repeat sends keydown without an intervening keyup; keep the
930
+ // original token open rather than starting a new one.
931
+ if (!openByCode.has(e.code)) {
932
+ var tok = { key: e.key, downT: e.t, upT: null };
933
+ openByCode.set(e.code, tok);
934
+ tokens.push(tok);
935
+ }
936
+ } else {
937
+ var open = openByCode.get(e.code);
938
+ if (open) { open.upT = e.t; openByCode.delete(e.code); }
939
+ }
940
+ }
941
+ return tokens.filter(function (tok) {
942
+ return tok.upT == null || T <= tok.upT + KEYCAST_FADE_MS;
943
+ });
944
+ }
945
+
946
+ function drawKeycast() {
947
+ var chips = computeKeycastChips(playhead);
948
+ keycast.textContent = '';
949
+ chips.forEach(function (tok) {
950
+ var chip = el('span', 'replay-key-chip' + (tok.redacted ? ' replay-key-chip--redacted' : ''),
951
+ tok.redacted ? '•' : formatKeyLabel(tok.key));
952
+ if (tok.upT != null && playhead > tok.upT) {
953
+ var frac = Math.min(1, (playhead - tok.upT) / KEYCAST_FADE_MS);
954
+ chip.style.opacity = String(1 - frac);
955
+ }
956
+ keycast.appendChild(chip);
957
+ });
958
+ }
959
+
816
960
  // ── Marker lane (per trial; redrawn per seek so check marks appear) ──
817
961
  function drawLane() {
818
962
  var lctx = lane.getContext('2d');
@@ -886,7 +1030,7 @@
886
1030
  }
887
1031
  var fails = counters.patchFailures + counters.scrollFailures;
888
1032
  if (fails > 0) {
889
- failChip.textContent = '⚠ ' + fails + ' recorded change(s) could not be re-applied — replay may diverge';
1033
+ failChip.textContent = '⚠ ' + fails + ' recorded change(s) could not be reapplied. Replay may diverge from the original session.';
890
1034
  failChip.style.display = '';
891
1035
  } else {
892
1036
  failChip.style.display = 'none';
@@ -919,6 +1063,11 @@
919
1063
  }
920
1064
  lastFrame = ts;
921
1065
  if (playhead >= trial().durMs) {
1066
+ if (autoAdvance && trialIdx < trials.length - 1) {
1067
+ loadTrial(trialIdx + 1); // continue playing into the next trial
1068
+ requestAnimationFrame(tick);
1069
+ return;
1070
+ }
922
1071
  playing = false;
923
1072
  playBtn.textContent = '▶';
924
1073
  playBtn.setAttribute('aria-label', 'Play');
@@ -950,11 +1099,18 @@
950
1099
  else if (ev.key === 'Home') { seek(0); ev.preventDefault(); }
951
1100
  else if (ev.key === 'End') { seek(trial().durMs); ev.preventDefault(); }
952
1101
  });
953
- function selectTrial(i) {
1102
+ function updateSessionPos() {
1103
+ sessionPos.textContent = 'Trial ' + (trialIdx + 1) + ' of ' + trials.length;
1104
+ }
1105
+
1106
+ // Loads trial i's DOM/camera/scrub state WITHOUT touching playing/playBtn
1107
+ // — the continuous-playback boundary in tick() below calls this directly
1108
+ // so play keeps running across the cut; selectTrial() (manual trial-pick
1109
+ // and boot) wraps it with the playing=false reset a user-driven jump
1110
+ // should have.
1111
+ function loadTrial(i) {
954
1112
  trialIdx = i;
955
- playing = false;
956
- playBtn.textContent = '▶';
957
- playBtn.setAttribute('aria-label', 'Play');
1113
+ trialSel.value = String(i);
958
1114
  scrub.max = String(trial().durMs);
959
1115
  trialHadIframe = false;
960
1116
  trialHadShadow = false;
@@ -965,8 +1121,8 @@
965
1121
  var recDpr = (trial().camera && trial().camera.dpr) ||
966
1122
  (model.viewport && model.viewport.dpr) || null;
967
1123
  if (recDpr && window.devicePixelRatio && recDpr !== window.devicePixelRatio) {
968
- dprChip.textContent = 'Recorded at DPR ' + recDpr + ', viewing at DPR ' +
969
- window.devicePixelRatio + ' — resolution-dependent styling may differ';
1124
+ dprChip.textContent = 'Recorded at DPR ' + recDpr + ', now viewing at DPR ' +
1125
+ window.devicePixelRatio + '. Resolution-dependent styling may differ.';
970
1126
  dprChip.style.display = '';
971
1127
  } else {
972
1128
  dprChip.style.display = 'none';
@@ -979,6 +1135,13 @@
979
1135
  flushCamSize(); // trace tier: transform initializes from the seed
980
1136
  }
981
1137
  seek(0);
1138
+ updateSessionPos();
1139
+ }
1140
+ function selectTrial(i) {
1141
+ playing = false;
1142
+ playBtn.textContent = '▶';
1143
+ playBtn.setAttribute('aria-label', 'Play');
1144
+ loadTrial(i);
982
1145
  }
983
1146
  trialSel.addEventListener('change', function () {
984
1147
  selectTrial(Number(trialSel.value) || 0);