cyborg-hunter 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ // src/cli/renderers/typing-profile-core.js
2
+ //
3
+ // Pure drawing core for per-participant typing-speed bar-chart images — no
4
+ // Node APIs, so a browser demo can bundle it directly (0.7.2-style
5
+ // extraction from cli/renderers/typing-profile.js, which is now a thin fs
6
+ // wrapper around this module: it acquires node-canvas, calls
7
+ // drawTypingProfile per participant, and writes the returned canvas to a
8
+ // PNG). Mirrors the session-timeline-core.js / trajectories-core.js split.
9
+ //
10
+ // drawTypingProfile(p, config, createCanvas) draws one participant's typing
11
+ // speed bar chart — one bar per trial's chars/sec, with a horizontal
12
+ // threshold line and bars above threshold colored red — and returns the
13
+ // drawn canvas (or null if there's nothing to draw: no trials, or no trial
14
+ // has either a typed speed or a paste event). createCanvas is injected so
15
+ // this module never imports the `canvas` package directly — a browser
16
+ // caller can pass a document-canvas-backed factory instead.
17
+
18
+ const BAR_WIDTH = 30;
19
+ const BAR_GAP = 8;
20
+ const CHART_HEIGHT = 200;
21
+ const TOP_PAD = 50;
22
+ const BOTTOM_PAD = 50;
23
+ const LEFT_PAD = 60;
24
+
25
+ // ── Per-participant renderer ─────────────────────────────────────────────
26
+ // Returns the drawn canvas, or null if there's no data to draw (caller
27
+ // decides what "no data" means — the fs wrapper skips the PNG write).
28
+ export function drawTypingProfile(p, config, createCanvas) {
29
+ const trials = p.trials;
30
+ if (trials.length === 0) return null;
31
+
32
+ // Per-participant threshold line: prefer the typing-speed cutoff the library
33
+ // actually screened this participant with (saved in session.config.thresholds)
34
+ // so the red "fast typing" line matches summary.csv's count; fall back to the
35
+ // CLI/default. A strict participant (8 cps) otherwise shows a 10 cps line.
36
+ const threshold =
37
+ p.session?.config?.thresholds?.typingSpeedCps ?? config.typingSpeedThreshold_cps ?? 10;
38
+
39
+ // Per-trial state. We distinguish three cases:
40
+ // "typed" — charsPerSec is a real number, draw a normal bar
41
+ // "paste-only" — charsPerSec is null but there's a paste event; draw a
42
+ // grey hatched marker so the absence is legible. Single
43
+ // paste fires one input event → editTimestamps.length < 2
44
+ // → computeTypingSpeed returns null. Without this, a
45
+ // pasted response looks identical to a very slow one.
46
+ // "no-data" — neither, leave blank
47
+ const kinds = trials.map(t => {
48
+ if (typeof t.charsPerSec === 'number') return 'typed';
49
+ if ((t.pasteEvents || []).length > 0) return 'paste-only';
50
+ return 'no-data';
51
+ });
52
+ const speeds = trials.map(t => (typeof t.charsPerSec === 'number' ? t.charsPerSec : 0));
53
+ if (kinds.every(k => k === 'no-data')) return null;
54
+
55
+ const canvasW = LEFT_PAD + trials.length * (BAR_WIDTH + BAR_GAP) + 20;
56
+ const canvasH = TOP_PAD + CHART_HEIGHT + BOTTOM_PAD;
57
+ const canvas = createCanvas(canvasW, canvasH);
58
+ const ctx = canvas.getContext('2d');
59
+
60
+ // Background
61
+ ctx.fillStyle = '#ffffff';
62
+ ctx.fillRect(0, 0, canvasW, canvasH);
63
+
64
+ // Title
65
+ ctx.fillStyle = '#333';
66
+ ctx.font = 'bold 14px sans-serif';
67
+ ctx.fillText(`Typing Speed Profile — ${p.participantId}`, LEFT_PAD, 20);
68
+ ctx.font = '10px sans-serif';
69
+ ctx.fillStyle = '#666';
70
+ ctx.fillText(`Threshold: ${threshold} chars/sec`, LEFT_PAD, 35);
71
+
72
+ // Legend — three states documented in the `kinds` comment above.
73
+ const legendY = 35;
74
+ const legendX0 = LEFT_PAD + 180;
75
+ ctx.fillStyle = '#2196F3';
76
+ ctx.fillRect(legendX0, legendY - 8, 12, 10);
77
+ ctx.fillStyle = '#666';
78
+ ctx.fillText('typed', legendX0 + 16, legendY);
79
+ ctx.fillStyle = '#f44336';
80
+ ctx.fillRect(legendX0 + 60, legendY - 8, 12, 10);
81
+ ctx.fillStyle = '#666';
82
+ ctx.fillText('> threshold', legendX0 + 76, legendY);
83
+ // Hatched swatch for paste-only
84
+ drawHatchedRect(ctx, legendX0 + 150, legendY - 8, 12, 10);
85
+ ctx.fillStyle = '#666';
86
+ ctx.fillText('paste (P)', legendX0 + 166, legendY);
87
+
88
+ // Y-axis scaling
89
+ const maxSpeed = Math.max(...speeds, threshold * 1.5);
90
+ const yScale = CHART_HEIGHT / maxSpeed;
91
+
92
+ // Y-axis labels
93
+ ctx.fillStyle = '#666';
94
+ ctx.font = '9px sans-serif';
95
+ const yTicks = 5;
96
+ for (let t = 0; t <= yTicks; t++) {
97
+ const val = (maxSpeed / yTicks) * t;
98
+ const y = TOP_PAD + CHART_HEIGHT - val * yScale;
99
+ ctx.fillText(`${val.toFixed(1)}`, 5, y + 3);
100
+ // Grid line
101
+ ctx.strokeStyle = '#eee';
102
+ ctx.lineWidth = 0.5;
103
+ ctx.beginPath();
104
+ ctx.moveTo(LEFT_PAD, y);
105
+ ctx.lineTo(canvasW - 10, y);
106
+ ctx.stroke();
107
+ }
108
+
109
+ // Threshold line
110
+ const threshY = TOP_PAD + CHART_HEIGHT - threshold * yScale;
111
+ ctx.strokeStyle = '#ff0000';
112
+ ctx.lineWidth = 1.5;
113
+ ctx.setLineDash([5, 3]);
114
+ ctx.beginPath();
115
+ ctx.moveTo(LEFT_PAD, threshY);
116
+ ctx.lineTo(canvasW - 10, threshY);
117
+ ctx.stroke();
118
+ ctx.setLineDash([]);
119
+
120
+ // Bars
121
+ const PASTE_BAR_H = CHART_HEIGHT * 0.2; // Paste-only marker height: fixed 20% of chart.
122
+ for (let i = 0; i < trials.length; i++) {
123
+ const kind = kinds[i];
124
+ const speed = speeds[i];
125
+ const x = LEFT_PAD + i * (BAR_WIDTH + BAR_GAP);
126
+
127
+ if (kind === 'paste-only') {
128
+ // Fixed-height hatched grey bar + "P" marker. The height is arbitrary
129
+ // — the point is visible presence, not magnitude (we have no CPS to
130
+ // show). Sits at the bottom like a normal bar.
131
+ const y = TOP_PAD + CHART_HEIGHT - PASTE_BAR_H;
132
+ drawHatchedRect(ctx, x, y, BAR_WIDTH, PASTE_BAR_H);
133
+ ctx.strokeStyle = '#888';
134
+ ctx.lineWidth = 0.5;
135
+ ctx.strokeRect(x, y, BAR_WIDTH, PASTE_BAR_H);
136
+ // "P" marker above the bar
137
+ ctx.fillStyle = '#555';
138
+ ctx.font = 'bold 11px sans-serif';
139
+ ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
140
+ } else if (kind === 'typed') {
141
+ const barH = speed * yScale;
142
+ const y = TOP_PAD + CHART_HEIGHT - barH;
143
+ ctx.fillStyle = speed > threshold ? '#f44336' : '#2196F3';
144
+ ctx.fillRect(x, y, BAR_WIDTH, barH);
145
+ ctx.strokeStyle = '#333';
146
+ ctx.lineWidth = 0.5;
147
+ ctx.strokeRect(x, y, BAR_WIDTH, barH);
148
+ if (speed > 0) {
149
+ ctx.fillStyle = '#fff';
150
+ ctx.font = 'bold 9px sans-serif';
151
+ ctx.fillText(speed.toFixed(1), x + 3, y + 12);
152
+ }
153
+ // Mixed: typed AND pasted in the same trial. The bar shows the typing
154
+ // rate (legitimate signal); the "P" glyph above it flags that *some*
155
+ // of the response came in via paste. Distinct from pure paste-only
156
+ // (which is a hatched bar, no colored fill).
157
+ if ((trials[i].pasteEvents || []).length > 0) {
158
+ ctx.fillStyle = '#555';
159
+ ctx.font = 'bold 11px sans-serif';
160
+ ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
161
+ }
162
+ }
163
+ // 'no-data' → render nothing (blank column)
164
+
165
+ // Trial label (always drawn, even for blank columns)
166
+ const trialId = trials[i].trialId || trials[i].ruleId || `T${i + 1}`;
167
+ ctx.fillStyle = '#333';
168
+ ctx.font = '8px sans-serif';
169
+ ctx.save();
170
+ ctx.translate(x + BAR_WIDTH / 2, TOP_PAD + CHART_HEIGHT + 10);
171
+ ctx.rotate(Math.PI / 4);
172
+ ctx.fillText(trialId.substring(0, 12), 0, 0);
173
+ ctx.restore();
174
+ }
175
+
176
+ // Baseline
177
+ ctx.strokeStyle = '#333';
178
+ ctx.lineWidth = 1;
179
+ ctx.beginPath();
180
+ ctx.moveTo(LEFT_PAD, TOP_PAD + CHART_HEIGHT);
181
+ ctx.lineTo(canvasW - 10, TOP_PAD + CHART_HEIGHT);
182
+ ctx.stroke();
183
+
184
+ return canvas;
185
+ }
186
+
187
+ // Cross-hatched fill for paste-only markers. Uses a light grey background
188
+ // with diagonal strokes so the bar reads as "distinct category, not zero".
189
+ function drawHatchedRect(ctx, x, y, w, h) {
190
+ ctx.save();
191
+ ctx.fillStyle = '#e8e8e8';
192
+ ctx.fillRect(x, y, w, h);
193
+ ctx.beginPath();
194
+ ctx.rect(x, y, w, h);
195
+ ctx.clip();
196
+ ctx.strokeStyle = '#888';
197
+ ctx.lineWidth = 0.8;
198
+ const step = 5;
199
+ for (let d = -h; d < w + h; d += step) {
200
+ ctx.beginPath();
201
+ ctx.moveTo(x + d, y);
202
+ ctx.lineTo(x + d + h, y + h);
203
+ ctx.stroke();
204
+ }
205
+ ctx.restore();
206
+ }
207
+
208
+ // Used by the fs wrapper to build the per-participant PNG filename.
209
+ export function sanitize(name) {
210
+ return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
211
+ }
@@ -1,177 +1,26 @@
1
1
  // src/cli/renderers/typing-profile.js
2
- // Renders per-participant typing speed bar chart using node-canvas.
3
- // Each bar = one trial's chars/sec. A horizontal threshold line shows
4
- // the configured CPS threshold. Bars above threshold are colored red.
5
-
2
+ // Thin fs wrapper around the pure drawing core (typing-profile-core.js).
3
+ // The core takes an injected createCanvas and returns the drawn canvas (or
4
+ // null when there's nothing to draw); this wrapper owns node-canvas
5
+ // acquisition and PNG serialization so the core can also run in a browser
6
+ // context (demo plot adapter) with document canvases.
6
7
  import { writeFileSync } from 'fs';
7
8
  import { join } from 'path';
8
-
9
- const BAR_WIDTH = 30;
10
- const BAR_GAP = 8;
11
- const CHART_HEIGHT = 200;
12
- const TOP_PAD = 50;
13
- const BOTTOM_PAD = 50;
14
- const LEFT_PAD = 60;
15
-
9
+ import { drawTypingProfile, sanitize } from './typing-profile-core.js';
10
+ // Load-bearing re-export: drawTypingProfile serves this wrapper's own loop
11
+ // below and the future browser demo plot adapter — mirrors
12
+ // session-timeline.js / trajectories.js. (renderTypingProfiles remains the
13
+ // only symbol external consumers import from this file — see
14
+ // tests/cli/renderers.test.js and src/cli/report.js.)
15
+ export { drawTypingProfile };
16
+
17
+ // ── Public API ───────────────────────────────────────────────────────────
16
18
  export async function renderTypingProfiles(participants, config) {
17
19
  const { createCanvas } = await import('canvas');
18
20
 
19
21
  for (const p of participants) {
20
- const trials = p.trials;
21
- if (trials.length === 0) continue;
22
-
23
- // Per-participant threshold line: prefer the typing-speed cutoff the library
24
- // actually screened this participant with (saved in session.config.thresholds)
25
- // so the red "fast typing" line matches summary.csv's count; fall back to the
26
- // CLI/default. A strict participant (8 cps) otherwise shows a 10 cps line.
27
- const threshold =
28
- p.session?.config?.thresholds?.typingSpeedCps ?? config.typingSpeedThreshold_cps ?? 10;
29
-
30
- // Per-trial state. We distinguish three cases:
31
- // "typed" — charsPerSec is a real number, draw a normal bar
32
- // "paste-only" — charsPerSec is null but there's a paste event; draw a
33
- // grey hatched marker so the absence is legible. Single
34
- // paste fires one input event → editTimestamps.length < 2
35
- // → computeTypingSpeed returns null. Without this, a
36
- // pasted response looks identical to a very slow one.
37
- // "no-data" — neither, leave blank
38
- const kinds = trials.map(t => {
39
- if (typeof t.charsPerSec === 'number') return 'typed';
40
- if ((t.pasteEvents || []).length > 0) return 'paste-only';
41
- return 'no-data';
42
- });
43
- const speeds = trials.map(t => (typeof t.charsPerSec === 'number' ? t.charsPerSec : 0));
44
- if (kinds.every(k => k === 'no-data')) continue;
45
-
46
- const canvasW = LEFT_PAD + trials.length * (BAR_WIDTH + BAR_GAP) + 20;
47
- const canvasH = TOP_PAD + CHART_HEIGHT + BOTTOM_PAD;
48
- const canvas = createCanvas(canvasW, canvasH);
49
- const ctx = canvas.getContext('2d');
50
-
51
- // Background
52
- ctx.fillStyle = '#ffffff';
53
- ctx.fillRect(0, 0, canvasW, canvasH);
54
-
55
- // Title
56
- ctx.fillStyle = '#333';
57
- ctx.font = 'bold 14px sans-serif';
58
- ctx.fillText(`Typing Speed Profile — ${p.participantId}`, LEFT_PAD, 20);
59
- ctx.font = '10px sans-serif';
60
- ctx.fillStyle = '#666';
61
- ctx.fillText(`Threshold: ${threshold} chars/sec`, LEFT_PAD, 35);
62
-
63
- // Legend — three states documented in the `kinds` comment above.
64
- const legendY = 35;
65
- const legendX0 = LEFT_PAD + 180;
66
- ctx.fillStyle = '#2196F3';
67
- ctx.fillRect(legendX0, legendY - 8, 12, 10);
68
- ctx.fillStyle = '#666';
69
- ctx.fillText('typed', legendX0 + 16, legendY);
70
- ctx.fillStyle = '#f44336';
71
- ctx.fillRect(legendX0 + 60, legendY - 8, 12, 10);
72
- ctx.fillStyle = '#666';
73
- ctx.fillText('> threshold', legendX0 + 76, legendY);
74
- // Hatched swatch for paste-only
75
- drawHatchedRect(ctx, legendX0 + 150, legendY - 8, 12, 10);
76
- ctx.fillStyle = '#666';
77
- ctx.fillText('paste (P)', legendX0 + 166, legendY);
78
-
79
- // Y-axis scaling
80
- const maxSpeed = Math.max(...speeds, threshold * 1.5);
81
- const yScale = CHART_HEIGHT / maxSpeed;
82
-
83
- // Y-axis labels
84
- ctx.fillStyle = '#666';
85
- ctx.font = '9px sans-serif';
86
- const yTicks = 5;
87
- for (let t = 0; t <= yTicks; t++) {
88
- const val = (maxSpeed / yTicks) * t;
89
- const y = TOP_PAD + CHART_HEIGHT - val * yScale;
90
- ctx.fillText(`${val.toFixed(1)}`, 5, y + 3);
91
- // Grid line
92
- ctx.strokeStyle = '#eee';
93
- ctx.lineWidth = 0.5;
94
- ctx.beginPath();
95
- ctx.moveTo(LEFT_PAD, y);
96
- ctx.lineTo(canvasW - 10, y);
97
- ctx.stroke();
98
- }
99
-
100
- // Threshold line
101
- const threshY = TOP_PAD + CHART_HEIGHT - threshold * yScale;
102
- ctx.strokeStyle = '#ff0000';
103
- ctx.lineWidth = 1.5;
104
- ctx.setLineDash([5, 3]);
105
- ctx.beginPath();
106
- ctx.moveTo(LEFT_PAD, threshY);
107
- ctx.lineTo(canvasW - 10, threshY);
108
- ctx.stroke();
109
- ctx.setLineDash([]);
110
-
111
- // Bars
112
- const PASTE_BAR_H = CHART_HEIGHT * 0.2; // Paste-only marker height: fixed 20% of chart.
113
- for (let i = 0; i < trials.length; i++) {
114
- const kind = kinds[i];
115
- const speed = speeds[i];
116
- const x = LEFT_PAD + i * (BAR_WIDTH + BAR_GAP);
117
-
118
- if (kind === 'paste-only') {
119
- // Fixed-height hatched grey bar + "P" marker. The height is arbitrary
120
- // — the point is visible presence, not magnitude (we have no CPS to
121
- // show). Sits at the bottom like a normal bar.
122
- const y = TOP_PAD + CHART_HEIGHT - PASTE_BAR_H;
123
- drawHatchedRect(ctx, x, y, BAR_WIDTH, PASTE_BAR_H);
124
- ctx.strokeStyle = '#888';
125
- ctx.lineWidth = 0.5;
126
- ctx.strokeRect(x, y, BAR_WIDTH, PASTE_BAR_H);
127
- // "P" marker above the bar
128
- ctx.fillStyle = '#555';
129
- ctx.font = 'bold 11px sans-serif';
130
- ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
131
- } else if (kind === 'typed') {
132
- const barH = speed * yScale;
133
- const y = TOP_PAD + CHART_HEIGHT - barH;
134
- ctx.fillStyle = speed > threshold ? '#f44336' : '#2196F3';
135
- ctx.fillRect(x, y, BAR_WIDTH, barH);
136
- ctx.strokeStyle = '#333';
137
- ctx.lineWidth = 0.5;
138
- ctx.strokeRect(x, y, BAR_WIDTH, barH);
139
- if (speed > 0) {
140
- ctx.fillStyle = '#fff';
141
- ctx.font = 'bold 9px sans-serif';
142
- ctx.fillText(speed.toFixed(1), x + 3, y + 12);
143
- }
144
- // Mixed: typed AND pasted in the same trial. The bar shows the typing
145
- // rate (legitimate signal); the "P" glyph above it flags that *some*
146
- // of the response came in via paste. Distinct from pure paste-only
147
- // (which is a hatched bar, no colored fill).
148
- if ((trials[i].pasteEvents || []).length > 0) {
149
- ctx.fillStyle = '#555';
150
- ctx.font = 'bold 11px sans-serif';
151
- ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
152
- }
153
- }
154
- // 'no-data' → render nothing (blank column)
155
-
156
- // Trial label (always drawn, even for blank columns)
157
- const trialId = trials[i].trialId || trials[i].ruleId || `T${i + 1}`;
158
- ctx.fillStyle = '#333';
159
- ctx.font = '8px sans-serif';
160
- ctx.save();
161
- ctx.translate(x + BAR_WIDTH / 2, TOP_PAD + CHART_HEIGHT + 10);
162
- ctx.rotate(Math.PI / 4);
163
- ctx.fillText(trialId.substring(0, 12), 0, 0);
164
- ctx.restore();
165
- }
166
-
167
- // Baseline
168
- ctx.strokeStyle = '#333';
169
- ctx.lineWidth = 1;
170
- ctx.beginPath();
171
- ctx.moveTo(LEFT_PAD, TOP_PAD + CHART_HEIGHT);
172
- ctx.lineTo(canvasW - 10, TOP_PAD + CHART_HEIGHT);
173
- ctx.stroke();
174
-
22
+ const canvas = drawTypingProfile(p, config, createCanvas);
23
+ if (!canvas) continue;
175
24
  const buf = canvas.toBuffer('image/png');
176
25
  const filename = `typing_profile_${sanitize(p.participantId)}.png`;
177
26
  writeFileSync(join(config.outputDir, 'images', filename), buf);
@@ -179,28 +28,3 @@ export async function renderTypingProfiles(participants, config) {
179
28
 
180
29
  console.log(` typing profiles — rendered`);
181
30
  }
182
-
183
- function sanitize(name) {
184
- return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
185
- }
186
-
187
- // Cross-hatched fill for paste-only markers. Uses a light grey background
188
- // with diagonal strokes so the bar reads as "distinct category, not zero".
189
- function drawHatchedRect(ctx, x, y, w, h) {
190
- ctx.save();
191
- ctx.fillStyle = '#e8e8e8';
192
- ctx.fillRect(x, y, w, h);
193
- ctx.beginPath();
194
- ctx.rect(x, y, w, h);
195
- ctx.clip();
196
- ctx.strokeStyle = '#888';
197
- ctx.lineWidth = 0.8;
198
- const step = 5;
199
- for (let d = -h; d < w + h; d += step) {
200
- ctx.beginPath();
201
- ctx.moveTo(x + d, y);
202
- ctx.lineTo(x + d + h, y + h);
203
- ctx.stroke();
204
- }
205
- ctx.restore();
206
- }
@@ -378,6 +378,23 @@ export function init(userConfig) {
378
378
  report.mouseMetrics = mouseMetrics;
379
379
  }
380
380
 
381
+ // Privacy gate: the raw per-sample mouse coordinates are a much
382
+ // higher-resolution behavioral trace than the derived mouseMetrics
383
+ // above. Persist them ONLY when explicitly enabled
384
+ // (collectForPostHoc.rawMouseTrack) — under the `mouseTrack` name,
385
+ // which is what extract-core's field map already expects (mouseTrack →
386
+ // mouseEvents), so a saved report round-trips through
387
+ // extractIntegrityData() without new glue. Otherwise drop the raw
388
+ // report.mouseEvents entirely — the mouseMetrics signal above still
389
+ // works, but the {x,y,t,type} samples never leave the browser. Before
390
+ // this gate, deepCopy(trialData) put the full raw track in every
391
+ // report regardless of the documented off-by-default rawMouseTrack
392
+ // toggle.
393
+ if (config.collectForPostHoc.rawMouseTrack) {
394
+ report.mouseTrack = report.mouseEvents;
395
+ }
396
+ delete report.mouseEvents;
397
+
381
398
  // Accumulate into session
382
399
  sessionData.trialsCompleted++;
383
400
 
@@ -35,7 +35,7 @@ import { buildReplayMeta } from '../replay/persistence.js';
35
35
  class CyborgHunterReplayExtension {
36
36
  static info = {
37
37
  name: 'cyborg-hunter-replay',
38
- version: '0.7.0',
38
+ version: '0.7.2',
39
39
  data: {} // per-trial return is {}; session meta goes via addProperties
40
40
  };
41
41
 
@@ -10,7 +10,7 @@ class CyborgHunterExtension {
10
10
  // and package.json). Hand-bumped on each release; if you forget, the
11
11
  // jsPsych developer console shows a stale number — the actual version
12
12
  // attached to data is read from window.CyborgHunter.VERSION at runtime.
13
- version: '0.7.0',
13
+ version: '0.7.2',
14
14
  data: { integrity: { type: 'object' } }
15
15
  };
16
16
 
@@ -905,8 +905,11 @@ export function fullscreenElementOf(doc) {
905
905
  }
906
906
 
907
907
  // ----- Entry trial ---------------------------------------------
908
- function createEntryTrial(opts = {}) {
909
- const message = opts.message || `
908
+ // Hoisted out of createEntryTrial (verbatim string move) so the frozen
909
+ // public API can expose it as `defaultEntryMessage` below — demo/demo.js
910
+ // renders this SAME string verbatim on its guard-entry step, so it can
911
+ // never drift out of sync with what a real participant actually sees.
912
+ const DEFAULT_ENTRY_MESSAGE = `
910
913
  <h2>Fullscreen mode required</h2>
911
914
  <p>To keep the experiment fair for everyone, we ask that this study be completed in fullscreen mode,
912
915
  with no browser sidebars (Gemini, Copilot, Edge sidebar, etc.) open, and with this tab focused.</p>
@@ -914,6 +917,9 @@ export function fullscreenElementOf(doc) {
914
917
  <p>We care about collecting high-quality data, and these rules help ensure a fair experience for all participants.</p>
915
918
  <p>Please close any sidebars now, then click the button below to continue in fullscreen.</p>
916
919
  `;
920
+
921
+ function createEntryTrial(opts = {}) {
922
+ const message = opts.message || DEFAULT_ENTRY_MESSAGE;
917
923
  return {
918
924
  type: jsPsychHtmlButtonResponse,
919
925
  stimulus: message,
@@ -1048,6 +1054,9 @@ export function fullscreenElementOf(doc) {
1048
1054
  createEntryTrial: function (opts) { return createEntryTrial(opts); },
1049
1055
  onViolation: function (handler) { return onViolation(handler); },
1050
1056
  getCurrentState: function () { return getCurrentState(); },
1057
+ // Added on the object literal BEFORE Object.freeze() below — a
1058
+ // post-freeze assignment would silently no-op.
1059
+ defaultEntryMessage: DEFAULT_ENTRY_MESSAGE,
1051
1060
  };
1052
1061
 
1053
1062
  Object.freeze(api);
@@ -0,0 +1,125 @@
1
+ // src/replay/viewer-model.js
2
+ // Wire SessionRecording → viewer model. Pure — no Node APIs — so a browser
3
+ // demo can bundle it directly (0.7.2 extraction from
4
+ // cli/renderers/replay-assets.js, which re-exports this for existing
5
+ // callers).
6
+ //
7
+ // This is one of exactly two allowed wire→viewer time-conversion points (the
8
+ // other lives in the CLI ingest path): SessionRecording carries ms since
9
+ // session start; the viewer speaks trial-relative ms.
10
+
11
+ /**
12
+ * Wire SessionRecording → viewer model. Times become trial-relative
13
+ * (t − t_load); null anchors (standalone implicit trials) degrade to the
14
+ * first event's time so durations are always finite.
15
+ */
16
+ export function buildViewerModel(recording) {
17
+ const md = recording.metadata || {};
18
+ const ext = recording.ch_extensions || {};
19
+
20
+ // ── Camera seeding (central, per Sol round-1 finding 8) ──
21
+ // New recordings carry a per-trial view_state seed. Legacy recordings
22
+ // don't — but the full event stream is present, so each trial's starting
23
+ // camera is reconstructed by folding all PRIOR trials' window-scroll and
24
+ // resize events over the session-start viewport. Initial scroll is assumed
25
+ // 0 (a recording that starts pre-scrolled with no scroll events is
26
+ // unrecoverable — that's what the viewer's legacy banner covers).
27
+ const vp = recording.viewport || {};
28
+ const vv = vp.visual_viewport || {};
29
+ // Scrollbar delta: legacy resize events carry only innerWidth/Height; the
30
+ // layout (client) width is estimated as w minus the session-start delta
31
+ // between innerWidth and the layout width (visual_viewport.width).
32
+ const sbW = (vp.width && (vp.client_width || vv.width))
33
+ ? vp.width - (vp.client_width || vv.width) : 0;
34
+ const sbH = (vp.height && (vp.client_height || vv.height))
35
+ ? vp.height - (vp.client_height || vv.height) : 0;
36
+ let foldState = {
37
+ x: 0, y: 0,
38
+ w: vp.width || null, h: vp.height || null,
39
+ cw: vp.client_width || vv.width || vp.width || null,
40
+ ch: vp.client_height || vv.height || vp.height || null,
41
+ dpr: vp.dpr || 1
42
+ };
43
+ const foldEvent = (state, e) => {
44
+ if (e.kind === 'scroll' && e.el == null && e.redacted == null) {
45
+ state.x = Number(e.x) || 0;
46
+ state.y = Number(e.y) || 0;
47
+ } else if (e.kind === 'resize') {
48
+ state.w = e.w != null ? e.w : state.w;
49
+ state.h = e.h != null ? e.h : state.h;
50
+ state.cw = e.cw != null ? e.cw : (e.w != null ? e.w - sbW : state.cw);
51
+ state.ch = e.ch != null ? e.ch : (e.h != null ? e.h - sbH : state.ch);
52
+ if (e.dpr != null) state.dpr = e.dpr;
53
+ }
54
+ return state;
55
+ };
56
+
57
+ // Defensive against malformed/truncated artifacts (a hand-edited or
58
+ // partially-written recording): non-array trials/events and null entries must
59
+ // degrade to empty rather than throw and abort the whole cohort report.
60
+ const rawTrials = Array.isArray(recording.trials) ? recording.trials : [];
61
+ const trials = rawTrials.map((trial) => {
62
+ trial = trial || {};
63
+ // Sort by absolute time before anchoring. RAF-coalesced input events flush
64
+ // with an EARLIER timestamp than events pushed after they were enqueued, so
65
+ // the recorded array is not strictly time-ordered; the viewer scrubs by
66
+ // scanning until the first future event and would otherwise mis-apply an
67
+ // out-of-order event on a seek. Stable sort keeps equal-time order.
68
+ const events = (Array.isArray(trial.events) ? trial.events : [])
69
+ .filter((e) => e && typeof e === 'object')
70
+ .slice()
71
+ .sort((a, b) => (Number(a.t) || 0) - (Number(b.t) || 0));
72
+ const anchor = trial.t_load != null ? trial.t_load
73
+ : (events.length > 0 ? events[0].t : 0);
74
+ const lastT = events.length > 0 ? events[events.length - 1].t : anchor;
75
+ const end = trial.t_end != null ? trial.t_end : lastT;
76
+ // Camera seed for THIS trial: recorded view_state, else the folded state
77
+ // as of the end of the previous trial. A recorded view_state also
78
+ // RESYNCS the fold — in a mixed recording (some trials seeded, some
79
+ // not: truncation, version mixes) a later unseeded trial must inherit
80
+ // real observed state, not a fold that ignored every observation.
81
+ if (trial.view_state) foldState = Object.assign({}, foldState, trial.view_state);
82
+ const camera = trial.view_state
83
+ ? Object.assign({}, trial.view_state, { source: 'view_state' })
84
+ : Object.assign({}, foldState, { source: 'folded' });
85
+ // Advance the fold across this trial's events for the NEXT trial's seed.
86
+ events.forEach((e) => foldEvent(foldState, e));
87
+ return {
88
+ index: trial.trial_index,
89
+ id: trial.trial_id,
90
+ plugin: trial.plugin,
91
+ durMs: Math.max(0, Math.round((end - anchor) * 10) / 10),
92
+ initialDom: trial.initial_dom || '',
93
+ camera,
94
+ events: events.map((e) => {
95
+ const out = Object.assign({}, e);
96
+ out.t = Math.max(0, Math.round(((Number(e.t) || 0) - anchor) * 10) / 10);
97
+ return out;
98
+ })
99
+ };
100
+ });
101
+
102
+ return {
103
+ pid: md.participant_id != null ? String(md.participant_id) : 'unknown',
104
+ tier: md.tier || 'trace',
105
+ keys: md.keys || null,
106
+ startTime: md.start_time || null,
107
+ endReason: md.end_reason || null,
108
+ recorder: md.recorder || null,
109
+ viewport: recording.viewport || null,
110
+ // Legacy = ANY trial lacks a view_state seed (all-legacy recordings and
111
+ // mixed/truncated ones alike): those trials replay on folded camera
112
+ // state, so the reduced-guarantees banner must show.
113
+ legacy: !rawTrials.every((t) => t && t.view_state),
114
+ markerAttr: ext.marker_attr || null,
115
+ // Session scrollbar delta (innerWidth − layout width): the viewer uses
116
+ // the same fallback chain as the folding above for legacy resize events.
117
+ scrollbar: { w: sbW, h: sbH },
118
+ stylesheets: (recording.stylesheets && recording.stylesheets.initial) || [],
119
+ scoring: ext.scoring || null,
120
+ guardViolations: ext.guard_violations || [],
121
+ captureStopped: !!ext.capture_stopped,
122
+ captureFailures: (ext.capture_failures || []).map((f) => f.channel),
123
+ trials
124
+ };
125
+ }
@@ -2,7 +2,7 @@
2
2
  // Default thresholds and preset configurations for Cyborg Hunter.
3
3
  // Single source of truth — used by both the browser library and CLI report tool.
4
4
 
5
- export const VERSION = "0.7.0";
5
+ export const VERSION = "0.7.2";
6
6
 
7
7
  // Default detection thresholds shared across presets.
8
8
  // Researchers can override any value at init() time.
@@ -0,0 +1,20 @@
1
+ // src/shared/paths.js
2
+ // Pure path-walking helper. No Node APIs — shared by CLI and browser preview.
3
+ // Used by: CLI ingest/renderers and the browser preview bundle.
4
+
5
+ // Resolves a possibly-dotted field path against an object (0.6.1).
6
+ // A flat key wins over a dotted walk, so data that literally contains a
7
+ // "metadata.sessionId" column stays addressable; otherwise the path is
8
+ // walked one segment at a time. Returns undefined when any segment is
9
+ // missing or a non-object is hit mid-path.
10
+ export function getByPath(obj, path) {
11
+ if (obj == null || typeof path !== 'string' || path.length === 0) return undefined;
12
+ if (Object.prototype.hasOwnProperty.call(obj, path)) return obj[path];
13
+ if (!path.includes('.')) return undefined;
14
+ let cur = obj;
15
+ for (const seg of path.split('.')) {
16
+ if (cur == null || typeof cur !== 'object') return undefined;
17
+ cur = cur[seg];
18
+ }
19
+ return cur;
20
+ }