cyborg-hunter 0.3.0 → 0.5.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.
@@ -1,27 +1,54 @@
1
- // src/cli/renderers/trajectories.js
2
- // Renders mouse trajectory images using node-canvas.
3
- // Produces one PNG per participant with a grid of panels (one per trial).
1
+ // Mouse trajectory rendering (node-canvas). One PNG per participant, grid of
2
+ // per-trial panels. Mirrors the visual conventions of the Python reference
3
+ // pipeline in card-games/rule-gallery/analysis/trajectories.py.
4
4
  //
5
- // Visual elements (matching the Python reference in trajectories.py):
6
- // - Time-colored path: blue→red gradient via line segments
7
- // - Start marker: blue circle, End marker: red square
8
- // - Mousedown: green triangle, Mouseup: magenta triangle
9
- // - Tab-away: yellow diamond (left), cyan diamond (returned), size ∝ duration
10
- // - Pause markers: grey circles for >3s gaps without tab-away
11
- // - Edge-exit highlights: orange overlay on edge-exit segments
12
- // - Screen/window bounding rectangles
13
- // - Red border if hard signal triggered on that trial
14
- // - Title with trial ID, RT, mouse count, tab-away info
5
+ // Per-panel visual elements:
6
+ // - Time-colored path (blue → red gradient over the trial's duration)
7
+ // - Start marker (blue circle), end marker (red square)
8
+ // - Mousedown (green ▼), mouseup (magenta ▲)
9
+ // - Tab-away pairs (yellow ◆ at the leave point, cyan ◆ at return, size ∝
10
+ // duration), connected by a dashed line
11
+ // - Pause circles (grey ○) for >3s gaps that don't overlap a tab-away
12
+ // - Browser-window outline (purple, dashed); screen outline (teal, solid)
13
+ // when geometry data is present and internally consistent
14
+ // - Red panel border if any hard signal fired on this trial
15
+ // - Title with trial ID, RT, mouse count, tab-away count + total duration
15
16
 
16
17
  import { writeFileSync } from 'fs';
17
18
  import { join } from 'path';
18
19
 
19
- // Panel dimensions (pixels)
20
+ // Panel dimensions (pixels).
20
21
  const PANEL_W = 400;
21
22
  const PANEL_H = 300;
22
23
  const PANEL_PAD = 40;
23
24
  const TITLE_HEIGHT = 40;
24
- const HEADER_HEIGHT = 80; // accommodates 2-line legend
25
+ const HEADER_HEIGHT = 80; // accommodates 2-line legend
26
+ const PANEL_MARGIN = 15; // inset between panel border and content area
27
+
28
+ // Color palette. Hoisted from inline string literals to give the rendering
29
+ // style one place to look. Names match the visual element they style.
30
+ const COLORS = {
31
+ bg: '#ffffff',
32
+ panelBg: '#fafafa',
33
+ panelBorder: '#ccc',
34
+ panelBorderHardHit: '#ff0000',
35
+ headerText: '#333',
36
+ legendText: '#666',
37
+ zoomTag: '#a06000',
38
+ noData: '#999',
39
+ screenRect: '#26a69a',
40
+ windowRect: '#7e57c2',
41
+ pathStart: '#0000ff',
42
+ pathEnd: '#ff0000',
43
+ pathEndStroke: '#000',
44
+ mousedown: '#00cc00',
45
+ mouseup: '#ff00ff',
46
+ tabAwayLeftFill: '#ffff00',
47
+ tabAwayLeftStroke: '#ff8c00',
48
+ tabAwayReturnFill: '#00ffff',
49
+ tabAwayReturnStroke: '#008b8b',
50
+ pauseCircle: '#ccc',
51
+ };
25
52
 
26
53
  export async function renderTrajectories(participants, triage, config) {
27
54
  const { createCanvas } = await import('canvas');
@@ -41,20 +68,20 @@ export async function renderTrajectories(participants, triage, config) {
41
68
  const ctx = canvas.getContext('2d');
42
69
 
43
70
  // Background
44
- ctx.fillStyle = '#ffffff';
71
+ ctx.fillStyle = COLORS.bg;
45
72
  ctx.fillRect(0, 0, canvasW, canvasH);
46
73
 
47
74
  // Header
48
75
  const t = triageMap.get(p.participantId);
49
76
  const headerText = `${p.participantId} — Score: ${t?.score ?? '?'} — ${t?.reason ?? ''}`;
50
- ctx.fillStyle = '#333';
77
+ ctx.fillStyle = COLORS.headerText;
51
78
  ctx.font = 'bold 16px sans-serif';
52
79
  ctx.fillText(headerText, PANEL_PAD, 30);
53
80
 
54
- // Legend — split across two lines so the long enumeration stays readable
55
- // and the panel-frame meaning isn't buried at the end of a wrap.
81
+ // Two-line legend — splitting keeps the panel-frame meaning visible rather
82
+ // than buried at the end of a wrap.
56
83
  ctx.font = '11px sans-serif';
57
- ctx.fillStyle = '#666';
84
+ ctx.fillStyle = COLORS.legendText;
58
85
  ctx.fillText(
59
86
  'Blue●=start Red■=end Green▼=mousedown Magenta▲=mouseup Yellow◆=tab-away (left) Cyan◆=tab-away (returned) Grey○=pause',
60
87
  PANEL_PAD, 48
@@ -64,30 +91,27 @@ export async function renderTrajectories(participants, triage, config) {
64
91
  PANEL_PAD, 64
65
92
  );
66
93
 
67
- // session.windowPositions is the 2-second-poll record from
68
- // core/signals/browser.js. Per-trial geometry is the sample whose
69
- // timestamp is closest to that trial's trialStart_perfNow anchor.
70
- // Older data without sw/sh (pre-April-2026 monitor) yields a
71
- // window-only geometry; chooseScreenFrame degrades gracefully.
94
+ // session.windowPositions is the 2-second-poll-plus-resize-event record
95
+ // from core/signals/browser.js. Per-trial geometry uses the sample whose
96
+ // timestamp best fits the trial's mouse extent (see
97
+ // pickWindowGeometryForTrial). Older recordings without sw/sh fields
98
+ // yield a window-only geometry; chooseScreenFrame degrades gracefully.
72
99
  const windowPositions = p.session?.windowPositions || [];
73
100
 
74
- // Render each trial panel
75
101
  for (let i = 0; i < trials.length; i++) {
76
102
  const col = i % cols;
77
103
  const row = Math.floor(i / cols);
78
104
  const x0 = PANEL_PAD + col * (PANEL_W + PANEL_PAD);
79
105
  const y0 = HEADER_HEIGHT + PANEL_PAD + row * (PANEL_H + TITLE_HEIGHT + PANEL_PAD);
80
106
 
81
- // P7: prefer per-trial geometry (collected at renderRule time) over
82
- // session-end metadata, which can be stale if the participant dragged
83
- // the window mid-experiment. Fall back to session metadata for old data.
107
+ // Geometry resolution priority: explicit per-trial > session-end > polled.
108
+ // Per-trial wins because it was captured AT trial render time and can't
109
+ // be stale; session-end can drift if the participant resized between
110
+ // last sample and end-of-experiment. Polled fills in for jsPsych
111
+ // extension data, which has no explicit per-trial geometry field.
84
112
  const sessionMeta = p.metadata || {};
85
113
  const sessionGeom = sessionMeta.geometry || {};
86
114
  const trialGeom = trials[i].geometry || {};
87
- // April 2026: also derive geometry from session.windowPositions if no
88
- // explicit per-trial geometry was attached. This is the path jsPsych
89
- // extension data takes (no per-trial geometry field, just the polled
90
- // session-level samples).
91
115
  const polledGeom = pickWindowGeometryForTrial(trials[i], windowPositions) || {};
92
116
  const effectiveMeta = { ...sessionMeta, ...sessionGeom, ...polledGeom, ...trialGeom };
93
117
  renderTrialPanel(ctx, trials[i], x0, y0, config, effectiveMeta);
@@ -110,48 +134,46 @@ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
110
134
  const downs = mouse.filter(e => e.type === 'down');
111
135
  const ups = mouse.filter(e => e.type === 'up');
112
136
 
113
- // Panel border — red if any hard-signal trialHits fired (v3.1.0 data shape).
114
- // Mirrors the `.triggered` → `trialHits > 0` migration in summary.js.
137
+ // Panel border — red if any hard-signal trialHits fired on this trial.
115
138
  const anyHardHit = trial.trialSignals?.hard &&
116
139
  Object.values(trial.trialSignals.hard).some(s => (s.trialHits || 0) > 0);
117
- ctx.strokeStyle = anyHardHit ? '#ff0000' : '#ccc';
140
+ ctx.strokeStyle = anyHardHit ? COLORS.panelBorderHardHit : COLORS.panelBorder;
118
141
  ctx.lineWidth = anyHardHit ? 3 : 1;
119
142
  ctx.strokeRect(x0, y0 + TITLE_HEIGHT, PANEL_W, PANEL_H);
120
143
 
121
144
  // Panel background
122
- ctx.fillStyle = '#fafafa';
145
+ ctx.fillStyle = COLORS.panelBg;
123
146
  ctx.fillRect(x0 + 1, y0 + TITLE_HEIGHT + 1, PANEL_W - 2, PANEL_H - 2);
124
147
 
125
148
  // Title
126
149
  const nTabs = tabAways.length;
127
150
  const tabDur = tabAways.reduce((s, e) => s + (e.duration_ms || 0), 0) / 1000;
128
- ctx.fillStyle = '#333';
151
+ ctx.fillStyle = COLORS.headerText;
129
152
  ctx.font = '11px sans-serif';
130
153
  ctx.fillText(`${trialId.substring(0, 20)} — ${rt.toFixed(0)}s, ${moves.length} mv, ${nTabs} tabs (${tabDur.toFixed(0)}s)`,
131
154
  x0, y0 + TITLE_HEIGHT - 8);
132
155
 
133
156
  if (moves.length === 0) {
134
- ctx.fillStyle = '#999';
157
+ ctx.fillStyle = COLORS.noData;
135
158
  ctx.font = '12px sans-serif';
136
159
  ctx.fillText('No mouse data', x0 + PANEL_W / 2 - 40, y0 + TITLE_HEIGHT + PANEL_H / 2);
137
160
  return;
138
161
  }
139
162
 
140
- // P7: choose a geometry frame and decide which rectangles can be drawn honestly.
141
- // Three cases, in priority order:
142
- // 1. SCREEN-API frame — metadata.screens (Window Management API) is present.
143
- // We know the multi-monitor layout, so we can render in
144
- // true screen space and always draw both rectangles.
145
- // 2. LEGACY-COMPAT frame — old data with screenWidth/screenHeight; we draw both
146
- // rectangles BUT only when the window geometry doesn't
147
- // paradoxically exceed the screen (zoom / multi-monitor).
148
- // When it does exceed, we drop the outer rect — drawing
149
- // a "screen" smaller than the "window" is dishonest.
150
- // 3. WINDOW-ONLY frame — window geometry only (no screen size known); fit
151
- // canvas to the window outline and draw it alone.
152
- // 4. AUTO-FIT frame — no geometry at all; auto-fit to mouse path.
163
+ // Choose a geometry frame and decide which rectangles can be drawn honestly.
164
+ // Four cases, in priority order:
165
+ // 1. SCREEN-API — metadata.screens (Window Management API) present.
166
+ // Multi-monitor layout known; render in true screen space
167
+ // and draw both rectangles.
168
+ // 2. LEGACY — screenWidth/screenHeight present. Draw both rects when
169
+ // the window geometry doesn't paradoxically exceed the
170
+ // screen (zoom-out, multi-monitor). When it does, drop
171
+ // the outer rect — drawing a "screen" smaller than the
172
+ // "window" it supposedly contains is dishonest.
173
+ // 3. WINDOW-ONLY — window geometry but no screen size. Fit canvas to the
174
+ // window outline and draw it alone.
175
+ // 4. AUTO-FIT — no geometry at all; auto-fit to mouse path.
153
176
  const screenFrame = chooseScreenFrame(metadata);
154
- const margin = 15;
155
177
  let minX, maxX, minY, maxY, offsetX, offsetY;
156
178
  if (screenFrame.mode === 'screen-api' || screenFrame.mode === 'legacy') {
157
179
  const { screenW, screenH, winX, winY, winW, winH } = screenFrame;
@@ -197,8 +219,8 @@ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
197
219
  // Skipped in auto-fit mode (no outline → no alignment needed).
198
220
  const zoom = (screenFrame.mode === 'auto-fit') ? { x: 1, y: 1 } : computeZoomScale(metadata);
199
221
 
200
- const mapX = (px) => x0 + margin + ((px * zoom.x + offsetX - minX) / rangeX) * (PANEL_W - 2 * margin);
201
- const mapY = (py) => y0 + TITLE_HEIGHT + margin + ((py * zoom.y + offsetY - minY) / rangeY) * (PANEL_H - 2 * margin);
222
+ const mapX = (px) => x0 + PANEL_MARGIN + ((px * zoom.x + offsetX - minX) / rangeX) * (PANEL_W - 2 * PANEL_MARGIN);
223
+ const mapY = (py) => y0 + TITLE_HEIGHT + PANEL_MARGIN + ((py * zoom.y + offsetY - minY) / rangeY) * (PANEL_H - 2 * PANEL_MARGIN);
202
224
 
203
225
  // Screen + browser-window rectangles. The frame chooser above decides which
204
226
  // rectangles are safe to draw. `drawOuter` is false for legacy data with a
@@ -207,19 +229,19 @@ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
207
229
  // also false for window-only mode (no screen data at all).
208
230
  if (screenFrame.mode === 'screen-api' || screenFrame.mode === 'legacy' || screenFrame.mode === 'window-only') {
209
231
  const { screenW, screenH, winX, winY, winW, winH, drawOuter } = screenFrame;
210
- const rectMapX = (px) => x0 + margin + ((px - minX) / rangeX) * (PANEL_W - 2 * margin);
211
- const rectMapY = (py) => y0 + TITLE_HEIGHT + margin + ((py - minY) / rangeY) * (PANEL_H - 2 * margin);
232
+ const rectMapX = (px) => x0 + PANEL_MARGIN + ((px - minX) / rangeX) * (PANEL_W - 2 * PANEL_MARGIN);
233
+ const rectMapY = (py) => y0 + TITLE_HEIGHT + PANEL_MARGIN + ((py - minY) / rangeY) * (PANEL_H - 2 * PANEL_MARGIN);
212
234
  ctx.save();
213
235
  ctx.lineWidth = 1;
214
236
  if (drawOuter) {
215
- ctx.strokeStyle = '#26a69a';
237
+ ctx.strokeStyle = COLORS.screenRect;
216
238
  ctx.strokeRect(
217
239
  rectMapX(0), rectMapY(0),
218
240
  rectMapX(screenW) - rectMapX(0),
219
241
  rectMapY(screenH) - rectMapY(0)
220
242
  );
221
243
  }
222
- ctx.strokeStyle = '#7e57c2';
244
+ ctx.strokeStyle = COLORS.windowRect;
223
245
  ctx.setLineDash([4, 3]);
224
246
  ctx.strokeRect(
225
247
  rectMapX(winX), rectMapY(winY),
@@ -236,7 +258,7 @@ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
236
258
  // from outerWidth/innerWidth deviation.
237
259
  const zoomTag = computeZoomTag(metadata);
238
260
  if (zoomTag) {
239
- ctx.fillStyle = '#a06000';
261
+ ctx.fillStyle = COLORS.zoomTag;
240
262
  ctx.font = 'italic 10px sans-serif';
241
263
  ctx.fillText(zoomTag, x0 + PANEL_W - 60, y0 + TITLE_HEIGHT - 8);
242
264
  }
@@ -258,34 +280,38 @@ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
258
280
  ctx.lineTo(mapX(moves[j].x), mapY(moves[j].y));
259
281
  ctx.stroke();
260
282
 
261
- // Pause markers (>3s gap without matching tab-away).
262
- // Both moves[j].t and ta.startRel_ms are trial-relative ms (normalized in ingest).
283
+ // Pause markers (>3s gap with no movement).
284
+ // Both moves[j].t and ta.startRel_ms are trial-relative ms (normalized in
285
+ // ingest). Find the tab-away (if any) whose [start, start+duration] window
286
+ // overlaps the gap. If we find one, draw the leave/return diamond pair;
287
+ // otherwise drop a grey pause circle.
263
288
  const gap = moves[j].t - moves[j - 1].t;
264
289
  if (gap > 3000) {
265
290
  const taStart = (ta) => (ta.startRel_ms != null ? ta.startRel_ms : ta.start);
266
- const isTabAway = tabAways.some(ta =>
267
- taStart(ta) < moves[j].t + 1000 && (taStart(ta) + (ta.duration_ms || 0)) > moves[j - 1].t - 1000);
268
- if (isTabAway) {
269
- // Tab-away diamond markers
270
- const dur = tabAways.find(ta => taStart(ta) < moves[j].t + 1000)?.duration_ms || gap;
291
+ const overlappingTab = tabAways.find(ta =>
292
+ taStart(ta) < moves[j].t + 1000 &&
293
+ (taStart(ta) + (ta.duration_ms || 0)) > moves[j - 1].t - 1000
294
+ );
295
+ if (overlappingTab) {
296
+ // Diamond size scales with √duration (square root keeps very long
297
+ // tab-aways from drawing as huge blobs that obscure the trajectory).
298
+ const dur = overlappingTab.duration_ms || gap;
271
299
  const size = Math.min(20, Math.max(4, 4 + Math.sqrt(dur / 1000) * 3));
272
300
 
273
- // Left diamond (yellow)
274
- drawDiamond(ctx, mapX(moves[j - 1].x), mapY(moves[j - 1].y), size, '#ffff00', '#ff8c00');
275
- // Return diamond (cyan)
276
- drawDiamond(ctx, mapX(moves[j].x), mapY(moves[j].y), size, '#00ffff', '#008b8b');
301
+ drawDiamond(ctx, mapX(moves[j - 1].x), mapY(moves[j - 1].y), size,
302
+ COLORS.tabAwayLeftFill, COLORS.tabAwayLeftStroke);
303
+ drawDiamond(ctx, mapX(moves[j].x), mapY(moves[j].y), size,
304
+ COLORS.tabAwayReturnFill, COLORS.tabAwayReturnStroke);
277
305
 
278
- // Dashed connector
279
306
  ctx.setLineDash([4, 3]);
280
- ctx.strokeStyle = '#ff8c00';
307
+ ctx.strokeStyle = COLORS.tabAwayLeftStroke;
281
308
  ctx.beginPath();
282
309
  ctx.moveTo(mapX(moves[j - 1].x), mapY(moves[j - 1].y));
283
310
  ctx.lineTo(mapX(moves[j].x), mapY(moves[j].y));
284
311
  ctx.stroke();
285
312
  ctx.setLineDash([]);
286
313
  } else {
287
- // Pause circle (grey)
288
- ctx.fillStyle = '#ccc';
314
+ ctx.fillStyle = COLORS.pauseCircle;
289
315
  ctx.beginPath();
290
316
  ctx.arc(mapX(moves[j - 1].x), mapY(moves[j - 1].y), 3, 0, Math.PI * 2);
291
317
  ctx.fill();
@@ -295,8 +321,8 @@ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
295
321
  ctx.globalAlpha = 1;
296
322
 
297
323
  // Start marker (blue circle)
298
- ctx.fillStyle = '#0000ff';
299
- ctx.strokeStyle = '#000';
324
+ ctx.fillStyle = COLORS.pathStart;
325
+ ctx.strokeStyle = COLORS.pathEndStroke;
300
326
  ctx.lineWidth = 0.5;
301
327
  ctx.beginPath();
302
328
  ctx.arc(mapX(moves[0].x), mapY(moves[0].y), 5, 0, Math.PI * 2);
@@ -306,18 +332,18 @@ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
306
332
  // End marker (red square)
307
333
  const ex = mapX(moves[moves.length - 1].x);
308
334
  const ey = mapY(moves[moves.length - 1].y);
309
- ctx.fillStyle = '#ff0000';
335
+ ctx.fillStyle = COLORS.pathEnd;
310
336
  ctx.fillRect(ex - 4, ey - 4, 8, 8);
311
337
  ctx.strokeRect(ex - 4, ey - 4, 8, 8);
312
338
 
313
- // Mousedown markers (green triangles)
314
- ctx.fillStyle = '#00cc00';
339
+ // Mousedown markers (green ▼)
340
+ ctx.fillStyle = COLORS.mousedown;
315
341
  for (const d of downs) {
316
342
  drawTriangle(ctx, mapX(d.x), mapY(d.y), 5, 'down');
317
343
  }
318
344
 
319
- // Mouseup markers (magenta triangles)
320
- ctx.fillStyle = '#ff00ff';
345
+ // Mouseup markers (magenta ▲)
346
+ ctx.fillStyle = COLORS.mouseup;
321
347
  for (const u of ups) {
322
348
  drawTriangle(ctx, mapX(u.x), mapY(u.y), 5, 'up');
323
349
  }
@@ -377,7 +403,7 @@ function sanitize(name) {
377
403
  // The screenWidth/screenHeight fields are conditionally included — the
378
404
  // April 2026 monitor change added sw/sh capture, but older recordings
379
405
  // won't have it. Omitting (rather than zero-filling) lets chooseScreenFrame
380
- // fall through its legacy/auto-fit branches honestly.
406
+ // fall through its compatibility/auto-fit branches honestly.
381
407
  export function pickWindowGeometryForTrial(trial, windowPositions) {
382
408
  if (!Array.isArray(windowPositions) || windowPositions.length === 0) return null;
383
409
  const anchor = trial?.trialStart_perfNow;
@@ -480,23 +506,31 @@ export function computeZoomScale(metadata) {
480
506
  };
481
507
  }
482
508
 
483
- // P7: pick the geometry frame and decide which rectangles are safe to draw.
484
- // Returns one of:
485
- // { mode: 'screen-api', screenW, screenH, winX, winY, winW, winH, drawOuter: true }
486
- // { mode: 'legacy', screenW, screenH, winX, winY, winW, winH, drawOuter }
509
+ // Picks the geometry frame and decides which rectangles are safe to draw.
510
+ // Returns one of FOUR shapes:
511
+ // { mode: 'screen-api', screenW, screenH, winX, winY, winW, winH, drawOuter: true }
512
+ // { mode: 'legacy', screenW, screenH, winX, winY, winW, winH, drawOuter }
513
+ // { mode: 'window-only', winX, winY, winW, winH, drawOuter: false }
487
514
  // { mode: 'auto-fit' }
488
515
  //
489
- // The 'screen-api' branch trusts metadata.screens (Window Management API). It
516
+ // The 'screen-api' branch trusts metadata.screens (Window Management API) and
490
517
  // uses the screen the window currently sits on (currentScreenLabel-matched)
491
- // rather than the primary monitor, eliminating the multi-monitor paradox.
518
+ // rather than the primary monitor — eliminates multi-monitor paradoxes.
519
+ //
520
+ // The 'legacy' branch covers data with screenWidth/screenHeight: draw both
521
+ // rects when the geometry is internally consistent, drop the outer rect when
522
+ // the window paradoxically exceeds the (primary-only) screen.
523
+ //
524
+ // The 'window-only' branch is for jsPsych extension data captured before
525
+ // the April 2026 monitor change added window.screen.width/height to its
526
+ // polled samples.
492
527
  //
493
- // The 'legacy' branch is the backward-compat path (Option B): draw both rects
494
- // when the geometry is internally consistent, drop the outer rect when the
495
- // window paradoxically exceeds the (primary-only) screen.
528
+ // The 'auto-fit' branch is the catch-all: no usable geometry, fit canvas to
529
+ // the mouse path.
496
530
  export function chooseScreenFrame(metadata) {
497
531
  if (!metadata) return { mode: 'auto-fit' };
498
532
 
499
- // Branch 1: Window Management API data present (post-P7 collection).
533
+ // Branch 1: Window Management API data present.
500
534
  if (Array.isArray(metadata.screens) && metadata.screens.length > 0) {
501
535
  // Pick the screen the window lives on. If currentScreenLabel matches a
502
536
  // labeled entry, use it; otherwise prefer the screen whose bounds contain
@@ -515,7 +549,7 @@ export function chooseScreenFrame(metadata) {
515
549
  };
516
550
  }
517
551
 
518
- // Branch 2: legacy data — primaryScreenWidth (post-P7 alias) or screenWidth.
552
+ // Branch 2: legacy data — primaryScreenWidth or screenWidth.
519
553
  const screenW = metadata.primaryScreenWidth ?? metadata.screenWidth;
520
554
  const screenH = metadata.primaryScreenHeight ?? metadata.screenHeight;
521
555
  if (screenW > 0 && screenH > 0) {
@@ -577,12 +611,20 @@ function pickCurrentScreen(metadata) {
577
611
  return screens.find(s => s.isPrimary) || screens[0];
578
612
  }
579
613
 
580
- // P7: compute a "zoom × N" tag when the viewport is scaled. Returns a string
581
- // like "zoom × 0.75" or null. Two heuristics:
582
- // 1. visualViewport.scale (post-P7 collection) — direct, exact.
583
- // 2. outerWidth/innerWidth ratio (post-P7 collection) — proxy for zoom level
584
- // that doesn't require visualViewport API.
585
- // Pre-P7 data has neither, so this returns null and no tag is shown.
614
+ // Compute a "zoom × N" tag for the panel title when the participant has
615
+ // adjusted browser zoom away from 100%. Returns a string like "zoom × 0.75"
616
+ // or null when zoom is within tolerance.
617
+ //
618
+ // Two heuristics, in order of directness:
619
+ // 1. visualViewport.scale — direct API value, exact for pinch zoom on touch
620
+ // devices. Doesn't catch CSS-level browser zoom (Cmd-Plus / Cmd-Minus on
621
+ // desktop), which has no JS API.
622
+ // 2. outerWidth / innerWidth ratio — proxy for CSS-level zoom. outer stays
623
+ // fixed in screen pixels; inner scales with zoom. Ratio ≈ 1 at 100%,
624
+ // < 1 zoomed out, > 1 zoomed in.
625
+ //
626
+ // Threshold of 10% (0.9 < ratio < 1.1) absorbs the typical chrome offset and
627
+ // scrollbar width; only flags clear deviation from 100%.
586
628
  export function computeZoomTag(metadata) {
587
629
  if (!metadata) return null;
588
630
  const tol = 0.05;
@@ -590,15 +632,15 @@ export function computeZoomTag(metadata) {
590
632
  if (typeof vvScale === 'number' && Math.abs(vvScale - 1) > tol) {
591
633
  return `zoom × ${vvScale.toFixed(2)}`;
592
634
  }
635
+ // Read innerWidth directly. Earlier versions fell back to windowWidth here
636
+ // — but in the polled-geometry pipeline, windowWidth is an alias for
637
+ // outerWidth, so the fallback gave outerWidth/outerWidth = 1 and the tag
638
+ // never fired. Use innerWidth strictly.
593
639
  const ow = metadata.outerWidth;
594
- const iw = metadata.windowWidth ?? metadata.innerWidth;
640
+ const iw = metadata.innerWidth;
595
641
  if (typeof ow === 'number' && typeof iw === 'number' && iw > 0) {
596
642
  const ratio = ow / iw;
597
- // Larger outer/inner means user zoomed out (viewport > chrome).
598
- // Smaller means zoomed in. Either way, flag deviation.
599
643
  if (Math.abs(ratio - 1) > 0.1) {
600
- // Browser chrome itself takes ~5-10% of outer in normal cases; only flag
601
- // if deviation is more extreme than typical chrome offset.
602
644
  const zoomEstimate = 1 / ratio;
603
645
  return `zoom × ${zoomEstimate.toFixed(2)}`;
604
646
  }
package/src/cli/report.js CHANGED
@@ -26,7 +26,21 @@ export async function run(args) {
26
26
  (warnings.length ? ` (${warnings.length} files had warnings)` : ''));
27
27
 
28
28
  if (participants.length === 0) {
29
- console.error('No valid participant data found. Check dataDir and filePattern.');
29
+ console.error(
30
+ `[cyborg-hunter] no valid participant data found in ${config.dataDir} (pattern: ${config.filePattern}).`
31
+ );
32
+ console.error(` Common causes:`);
33
+ console.error(` - dataDir points to the wrong directory`);
34
+ console.error(` - filePattern doesn't match your file extensions (try "*.csv" or "*.{json,csv}")`);
35
+ console.error(` - participantIdField doesn't match the actual field in your data`);
36
+ console.error(` (jsPsych output usually uses "subject_ID", not "participantId")`);
37
+ if (warnings.length > 0) {
38
+ console.error(` Per-file warnings (${warnings.length}):`);
39
+ for (const w of warnings.slice(0, 3)) {
40
+ console.error(` - ${w.file}: ${w.warnings[0]}`);
41
+ }
42
+ if (warnings.length > 3) console.error(` ... and ${warnings.length - 3} more`);
43
+ }
30
44
  process.exit(1);
31
45
  }
32
46
 
@@ -36,7 +36,7 @@ export function init(userConfig) {
36
36
  var presetName = userConfig.preset || "standard";
37
37
  var preset = PRESETS[presetName];
38
38
  if (!preset) {
39
- throw new Error("IntegrityMonitor: unknown preset '" + presetName + "'. Use: permissive, standard, strict.");
39
+ throw new Error("[cyborg-hunter] unknown preset '" + presetName + "'. Use: permissive, standard, strict.");
40
40
  }
41
41
 
42
42
  // Build effective config: DEFAULT_THRESHOLDS → preset → user overrides.
@@ -75,19 +75,18 @@ export function init(userConfig) {
75
75
  decoyMap: userConfig.decoyMap || null,
76
76
  decoyVisibility: userConfig.decoyVisibility || "offscreen",
77
77
  decoyFraming: userConfig.decoyFraming || "answer-key",
78
- decoyExcludeButtons: userConfig.decoyExcludeButtons || [".jspsych-btn"],
79
- _debug: userConfig._debug || false
78
+ decoyExcludeButtons: userConfig.decoyExcludeButtons || [".jspsych-btn"]
80
79
  };
81
80
 
82
- // Validate config keys — warn on typos
81
+ // Validate config keys — warn on typos.
83
82
  var KNOWN_KEYS = [
84
83
  "participantId", "preset", "signals", "thresholds", "scoring",
85
84
  "screenout", "domProtection", "collectForPostHoc", "decoyAnswers",
86
85
  "decoyMap", "decoyVisibility", "decoyFraming", "decoyExcludeButtons",
87
- "onSignal", "experimentContainer", "knownInputs", "_debug"
86
+ "onSignal", "experimentContainer", "knownInputs"
88
87
  ];
89
88
  var warnings = validateConfig(userConfig, KNOWN_KEYS);
90
- warnings.forEach(function (w) { console.warn("IntegrityMonitor: " + w); });
89
+ warnings.forEach(function (w) { console.warn("[cyborg-hunter] " + w); });
91
90
 
92
91
  // Freeze config
93
92
  Object.freeze(config);
@@ -159,8 +158,15 @@ export function init(userConfig) {
159
158
 
160
159
  function transition(to) {
161
160
  if (!sm.transition(to)) {
161
+ // Common causes: calling startTrial() while another trial is open
162
+ // (forgot endTrial?), calling endTrial() before startTrial(), or
163
+ // calling anything after destroy(). Show what was expected vs got
164
+ // so the user can match the lifecycle.
162
165
  throw new Error(
163
- "IntegrityMonitor: invalid transition " + sm.current + " → " + to
166
+ "[cyborg-hunter] invalid lifecycle call: cannot transition from '" +
167
+ sm.current + "' to '" + to + "'. " +
168
+ "Expected order: init() → startSession() → (startTrial → endTrial)* → destroy(). " +
169
+ "Common cause: a startTrial() without a preceding endTrial(), or a call after destroy()."
164
170
  );
165
171
  }
166
172
  }
@@ -240,34 +246,45 @@ export function init(userConfig) {
240
246
  syntheticInsertions: []
241
247
  };
242
248
 
243
- // Decoy injection
249
+ // Decoy injection. Logic in one place instead of four scattered branches:
250
+ // - decoyAnswers off + opts.decoyAnswer set → warn, no injection
251
+ // - opts.decoyAnswer === false → record explicit skip
252
+ // - opts.decoyAnswer is a non-empty string OR
253
+ // config.decoyMap has an entry for this trial → inject NOW
254
+ // - anything else → defer via setTimeout(0)
255
+ // (gives DOM time to settle
256
+ // when startTrial runs
257
+ // before the trial UI is up)
258
+ // Invalid (non-string, non-false, non-null) values get a warning before
259
+ // the deferred inject.
244
260
  removeDecoyElement();
245
- if (config.decoyAnswers) {
246
- var currentTrialId = trialData.trialId;
247
- if (opts.decoyAnswer === false) {
248
- trialData.decoy = { level: 0, source: "skipped" };
249
- } else if (typeof opts.decoyAnswer === "string" && opts.decoyAnswer.length > 0) {
250
- injectDecoy(opts, currentTrialId, config, trialData, function () { return trialData; });
251
- } else if (config.decoyMap && config.decoyMap[currentTrialId]) {
252
- injectDecoy(opts, currentTrialId, config, trialData, function () { return trialData; });
253
- } else if (opts.decoyAnswer != null && opts.decoyAnswer !== false) {
261
+ if (!config.decoyAnswers) {
262
+ if (opts.decoyAnswer != null) {
254
263
  console.warn(
255
- "IntegrityMonitor: decoyAnswer must be a non-empty string, got: " +
256
- typeof opts.decoyAnswer + ". Falling through to auto-detection."
264
+ "[cyborg-hunter] decoyAnswer provided but decoyAnswers is not enabled. " +
265
+ "Add decoyAnswers: true to init() config."
257
266
  );
258
- setTimeout(function () {
259
- injectDecoy(opts, currentTrialId, config, trialData, function () { return trialData; });
260
- }, 0);
267
+ }
268
+ } else if (opts.decoyAnswer === false) {
269
+ trialData.decoy = { level: 0, source: "skipped" };
270
+ } else {
271
+ var currentTrialId = trialData.trialId;
272
+ var inject = function () {
273
+ injectDecoy(opts, currentTrialId, config, trialData, function () { return trialData; });
274
+ };
275
+ var hasExplicitString = typeof opts.decoyAnswer === "string" && opts.decoyAnswer.length > 0;
276
+ var hasMapEntry = config.decoyMap && config.decoyMap[currentTrialId];
277
+ if (hasExplicitString || hasMapEntry) {
278
+ inject();
261
279
  } else {
262
- setTimeout(function () {
263
- injectDecoy(opts, currentTrialId, config, trialData, function () { return trialData; });
264
- }, 0);
280
+ if (opts.decoyAnswer != null) {
281
+ console.warn(
282
+ "[cyborg-hunter] decoyAnswer must be a non-empty string, got: " +
283
+ typeof opts.decoyAnswer + ". Falling through to auto-detection."
284
+ );
285
+ }
286
+ setTimeout(inject, 0);
265
287
  }
266
- } else if (opts.decoyAnswer != null) {
267
- console.warn(
268
- "IntegrityMonitor: decoyAnswer provided but decoyAnswers is not enabled. " +
269
- "Add decoyAnswers: true to init() config."
270
- );
271
288
  }
272
289
 
273
290
  // Attach trial-scoped signal listeners
@@ -188,7 +188,7 @@ export function injectDecoy(opts, expectedTrialId, config, trialData, getTrialDa
188
188
  } else if (config.decoyMap) {
189
189
  // decoyMap configured but trialId not found — Level 4 calibration fallback
190
190
  console.warn(
191
- 'IntegrityMonitor: No decoyMap entry for trialId "' + expectedTrialId +
191
+ '[cyborg-hunter] no decoyMap entry for trialId "' + expectedTrialId +
192
192
  '". Falling back to Level 3/4.'
193
193
  );
194
194
  var fallbackText = buildDecoyText(null, "calibration-metadata", null, _decoyTrialIndex);
@@ -26,7 +26,7 @@ export function createStateMachine() {
26
26
  state = to;
27
27
  return true;
28
28
  }
29
- console.warn("[CyborgHunter] Invalid transition: " + state + " → " + to);
29
+ console.warn("[cyborg-hunter] invalid transition: " + state + " → " + to);
30
30
  return false;
31
31
  }
32
32
  };