cyborg-hunter 0.3.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.
@@ -0,0 +1,607 @@
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).
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
15
+
16
+ import { writeFileSync } from 'fs';
17
+ import { join } from 'path';
18
+
19
+ // Panel dimensions (pixels)
20
+ const PANEL_W = 400;
21
+ const PANEL_H = 300;
22
+ const PANEL_PAD = 40;
23
+ const TITLE_HEIGHT = 40;
24
+ const HEADER_HEIGHT = 80; // accommodates 2-line legend
25
+
26
+ export async function renderTrajectories(participants, triage, config) {
27
+ const { createCanvas } = await import('canvas');
28
+ const triageMap = new Map(triage.map(t => [t.participantId, t]));
29
+
30
+ for (const p of participants) {
31
+ const trials = p.trials;
32
+ if (trials.length === 0) continue;
33
+
34
+ // Compute grid layout
35
+ const cols = Math.min(5, trials.length);
36
+ const rows = Math.ceil(trials.length / cols);
37
+
38
+ const canvasW = cols * (PANEL_W + PANEL_PAD) + PANEL_PAD;
39
+ const canvasH = HEADER_HEIGHT + rows * (PANEL_H + TITLE_HEIGHT + PANEL_PAD) + PANEL_PAD;
40
+ const canvas = createCanvas(canvasW, canvasH);
41
+ const ctx = canvas.getContext('2d');
42
+
43
+ // Background
44
+ ctx.fillStyle = '#ffffff';
45
+ ctx.fillRect(0, 0, canvasW, canvasH);
46
+
47
+ // Header
48
+ const t = triageMap.get(p.participantId);
49
+ const headerText = `${p.participantId} — Score: ${t?.score ?? '?'} — ${t?.reason ?? ''}`;
50
+ ctx.fillStyle = '#333';
51
+ ctx.font = 'bold 16px sans-serif';
52
+ ctx.fillText(headerText, PANEL_PAD, 30);
53
+
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.
56
+ ctx.font = '11px sans-serif';
57
+ ctx.fillStyle = '#666';
58
+ ctx.fillText(
59
+ 'Blue●=start Red■=end Green▼=mousedown Magenta▲=mouseup Yellow◆=tab-away (left) Cyan◆=tab-away (returned) Grey○=pause',
60
+ PANEL_PAD, 48
61
+ );
62
+ ctx.fillText(
63
+ 'Teal outer rect=screen Purple dashed rect=browser window Red panel frame=hard signal triggered on this trial',
64
+ PANEL_PAD, 64
65
+ );
66
+
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.
72
+ const windowPositions = p.session?.windowPositions || [];
73
+
74
+ // Render each trial panel
75
+ for (let i = 0; i < trials.length; i++) {
76
+ const col = i % cols;
77
+ const row = Math.floor(i / cols);
78
+ const x0 = PANEL_PAD + col * (PANEL_W + PANEL_PAD);
79
+ const y0 = HEADER_HEIGHT + PANEL_PAD + row * (PANEL_H + TITLE_HEIGHT + PANEL_PAD);
80
+
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.
84
+ const sessionMeta = p.metadata || {};
85
+ const sessionGeom = sessionMeta.geometry || {};
86
+ 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
+ const polledGeom = pickWindowGeometryForTrial(trials[i], windowPositions) || {};
92
+ const effectiveMeta = { ...sessionMeta, ...sessionGeom, ...polledGeom, ...trialGeom };
93
+ renderTrialPanel(ctx, trials[i], x0, y0, config, effectiveMeta);
94
+ }
95
+
96
+ const buf = canvas.toBuffer('image/png');
97
+ const filename = `trajectories_${sanitize(p.participantId)}.png`;
98
+ writeFileSync(join(config.outputDir, 'images', filename), buf);
99
+ }
100
+
101
+ console.log(` trajectories — ${participants.length} images`);
102
+ }
103
+
104
+ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
105
+ const mouse = trial.mouseEvents || [];
106
+ const tabAways = trial.tabAwayEvents || [];
107
+ const trialId = trial.trialId || trial.ruleId || '?';
108
+ const rt = (trial.duration_ms || trial.responseTime_ms || 0) / 1000;
109
+ const moves = mouse.filter(e => e.type === 'move');
110
+ const downs = mouse.filter(e => e.type === 'down');
111
+ const ups = mouse.filter(e => e.type === 'up');
112
+
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.
115
+ const anyHardHit = trial.trialSignals?.hard &&
116
+ Object.values(trial.trialSignals.hard).some(s => (s.trialHits || 0) > 0);
117
+ ctx.strokeStyle = anyHardHit ? '#ff0000' : '#ccc';
118
+ ctx.lineWidth = anyHardHit ? 3 : 1;
119
+ ctx.strokeRect(x0, y0 + TITLE_HEIGHT, PANEL_W, PANEL_H);
120
+
121
+ // Panel background
122
+ ctx.fillStyle = '#fafafa';
123
+ ctx.fillRect(x0 + 1, y0 + TITLE_HEIGHT + 1, PANEL_W - 2, PANEL_H - 2);
124
+
125
+ // Title
126
+ const nTabs = tabAways.length;
127
+ const tabDur = tabAways.reduce((s, e) => s + (e.duration_ms || 0), 0) / 1000;
128
+ ctx.fillStyle = '#333';
129
+ ctx.font = '11px sans-serif';
130
+ ctx.fillText(`${trialId.substring(0, 20)} — ${rt.toFixed(0)}s, ${moves.length} mv, ${nTabs} tabs (${tabDur.toFixed(0)}s)`,
131
+ x0, y0 + TITLE_HEIGHT - 8);
132
+
133
+ if (moves.length === 0) {
134
+ ctx.fillStyle = '#999';
135
+ ctx.font = '12px sans-serif';
136
+ ctx.fillText('No mouse data', x0 + PANEL_W / 2 - 40, y0 + TITLE_HEIGHT + PANEL_H / 2);
137
+ return;
138
+ }
139
+
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.
153
+ const screenFrame = chooseScreenFrame(metadata);
154
+ const margin = 15;
155
+ let minX, maxX, minY, maxY, offsetX, offsetY;
156
+ if (screenFrame.mode === 'screen-api' || screenFrame.mode === 'legacy') {
157
+ const { screenW, screenH, winX, winY, winW, winH } = screenFrame;
158
+ minX = 0;
159
+ minY = 0;
160
+ maxX = Math.max(screenW, winX + winW);
161
+ maxY = Math.max(screenH, winY + winH);
162
+ offsetX = winX;
163
+ offsetY = winY;
164
+ } else if (screenFrame.mode === 'window-only') {
165
+ // Frame the canvas around the window itself. Mouse coords are
166
+ // viewport-relative (0..innerWidth), so adding offsetX=winX places the
167
+ // trajectory inside the window outline at roughly the right spot. The
168
+ // chrome offset isn't accounted for, but for a no-screen visualization
169
+ // this is honest enough — the window outline shows scale and position.
170
+ const { winX, winY, winW, winH } = screenFrame;
171
+ minX = winX;
172
+ minY = winY;
173
+ maxX = winX + winW;
174
+ maxY = winY + winH;
175
+ offsetX = winX;
176
+ offsetY = winY;
177
+ } else {
178
+ // Auto-fit fallback
179
+ const allX = moves.map(m => m.x);
180
+ const allY = moves.map(m => m.y);
181
+ minX = Math.min(...allX) - 20;
182
+ maxX = Math.max(...allX) + 20;
183
+ minY = Math.min(...allY) - 20;
184
+ maxY = Math.max(...allY) + 20;
185
+ offsetX = 0;
186
+ offsetY = 0;
187
+ }
188
+ const rangeX = maxX - minX || 1;
189
+ const rangeY = maxY - minY || 1;
190
+
191
+ // Zoom-robust mouse plotting. Mouse coords (clientX/Y) are in viewport CSS
192
+ // pixels; the window outline is in screen CSS pixels. At non-100% browser
193
+ // zoom these scales differ — clicks would plot outside the outline.
194
+ // Scaling mouse coords by outer/inner ratio aligns them with the outline.
195
+ // Critically, X and Y scales are computed separately because chrome
196
+ // geometry isn't symmetric (top tabs+address bar vs ~zero left/right).
197
+ // Skipped in auto-fit mode (no outline → no alignment needed).
198
+ const zoom = (screenFrame.mode === 'auto-fit') ? { x: 1, y: 1 } : computeZoomScale(metadata);
199
+
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);
202
+
203
+ // Screen + browser-window rectangles. The frame chooser above decides which
204
+ // rectangles are safe to draw. `drawOuter` is false for legacy data with a
205
+ // window/screen paradox (zoom-out, multi-monitor) — we'd rather show nothing
206
+ // than a screen rect smaller than the window it supposedly contains. It's
207
+ // also false for window-only mode (no screen data at all).
208
+ if (screenFrame.mode === 'screen-api' || screenFrame.mode === 'legacy' || screenFrame.mode === 'window-only') {
209
+ 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);
212
+ ctx.save();
213
+ ctx.lineWidth = 1;
214
+ if (drawOuter) {
215
+ ctx.strokeStyle = '#26a69a';
216
+ ctx.strokeRect(
217
+ rectMapX(0), rectMapY(0),
218
+ rectMapX(screenW) - rectMapX(0),
219
+ rectMapY(screenH) - rectMapY(0)
220
+ );
221
+ }
222
+ ctx.strokeStyle = '#7e57c2';
223
+ ctx.setLineDash([4, 3]);
224
+ ctx.strokeRect(
225
+ rectMapX(winX), rectMapY(winY),
226
+ rectMapX(winX + winW) - rectMapX(winX),
227
+ rectMapY(winY + winH) - rectMapY(winY)
228
+ );
229
+ ctx.setLineDash([]);
230
+ ctx.restore();
231
+ }
232
+
233
+ // Zoom indicator — appended to the title. Honest signal that the viewport
234
+ // is scaled, so the viewer doesn't try to compare px counts across panels
235
+ // with different effective scales. Detected from visualViewport.scale or
236
+ // from outerWidth/innerWidth deviation.
237
+ const zoomTag = computeZoomTag(metadata);
238
+ if (zoomTag) {
239
+ ctx.fillStyle = '#a06000';
240
+ ctx.font = 'italic 10px sans-serif';
241
+ ctx.fillText(zoomTag, x0 + PANEL_W - 60, y0 + TITLE_HEIGHT - 8);
242
+ }
243
+
244
+ // Time normalization for color gradient
245
+ const ts = moves.map(m => m.t);
246
+ const tMin = Math.min(...ts);
247
+ const tMax = Math.max(...ts);
248
+ const tRange = tMax - tMin || 1;
249
+
250
+ // Draw path segments with time-based color (blue→red)
251
+ ctx.lineWidth = 1;
252
+ ctx.globalAlpha = 0.6;
253
+ for (let j = 1; j < moves.length; j++) {
254
+ const tNorm = (moves[j].t - tMin) / tRange;
255
+ ctx.strokeStyle = timeColor(tNorm);
256
+ ctx.beginPath();
257
+ ctx.moveTo(mapX(moves[j - 1].x), mapY(moves[j - 1].y));
258
+ ctx.lineTo(mapX(moves[j].x), mapY(moves[j].y));
259
+ ctx.stroke();
260
+
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).
263
+ const gap = moves[j].t - moves[j - 1].t;
264
+ if (gap > 3000) {
265
+ 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;
271
+ const size = Math.min(20, Math.max(4, 4 + Math.sqrt(dur / 1000) * 3));
272
+
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');
277
+
278
+ // Dashed connector
279
+ ctx.setLineDash([4, 3]);
280
+ ctx.strokeStyle = '#ff8c00';
281
+ ctx.beginPath();
282
+ ctx.moveTo(mapX(moves[j - 1].x), mapY(moves[j - 1].y));
283
+ ctx.lineTo(mapX(moves[j].x), mapY(moves[j].y));
284
+ ctx.stroke();
285
+ ctx.setLineDash([]);
286
+ } else {
287
+ // Pause circle (grey)
288
+ ctx.fillStyle = '#ccc';
289
+ ctx.beginPath();
290
+ ctx.arc(mapX(moves[j - 1].x), mapY(moves[j - 1].y), 3, 0, Math.PI * 2);
291
+ ctx.fill();
292
+ }
293
+ }
294
+ }
295
+ ctx.globalAlpha = 1;
296
+
297
+ // Start marker (blue circle)
298
+ ctx.fillStyle = '#0000ff';
299
+ ctx.strokeStyle = '#000';
300
+ ctx.lineWidth = 0.5;
301
+ ctx.beginPath();
302
+ ctx.arc(mapX(moves[0].x), mapY(moves[0].y), 5, 0, Math.PI * 2);
303
+ ctx.fill();
304
+ ctx.stroke();
305
+
306
+ // End marker (red square)
307
+ const ex = mapX(moves[moves.length - 1].x);
308
+ const ey = mapY(moves[moves.length - 1].y);
309
+ ctx.fillStyle = '#ff0000';
310
+ ctx.fillRect(ex - 4, ey - 4, 8, 8);
311
+ ctx.strokeRect(ex - 4, ey - 4, 8, 8);
312
+
313
+ // Mousedown markers (green triangles)
314
+ ctx.fillStyle = '#00cc00';
315
+ for (const d of downs) {
316
+ drawTriangle(ctx, mapX(d.x), mapY(d.y), 5, 'down');
317
+ }
318
+
319
+ // Mouseup markers (magenta triangles)
320
+ ctx.fillStyle = '#ff00ff';
321
+ for (const u of ups) {
322
+ drawTriangle(ctx, mapX(u.x), mapY(u.y), 5, 'up');
323
+ }
324
+ }
325
+
326
+ // Interpolates blue→red based on normalized time (0→1)
327
+ function timeColor(t) {
328
+ const r = Math.round(t * 255);
329
+ const b = Math.round((1 - t) * 255);
330
+ return `rgb(${r}, 50, ${b})`;
331
+ }
332
+
333
+ function drawDiamond(ctx, x, y, size, fill, stroke) {
334
+ ctx.fillStyle = fill;
335
+ ctx.strokeStyle = stroke;
336
+ ctx.lineWidth = 1.5;
337
+ ctx.beginPath();
338
+ ctx.moveTo(x, y - size);
339
+ ctx.lineTo(x + size, y);
340
+ ctx.lineTo(x, y + size);
341
+ ctx.lineTo(x - size, y);
342
+ ctx.closePath();
343
+ ctx.fill();
344
+ ctx.stroke();
345
+ }
346
+
347
+ function drawTriangle(ctx, x, y, size, direction) {
348
+ ctx.beginPath();
349
+ if (direction === 'down') {
350
+ ctx.moveTo(x - size, y - size);
351
+ ctx.lineTo(x + size, y - size);
352
+ ctx.lineTo(x, y + size);
353
+ } else {
354
+ ctx.moveTo(x - size, y + size);
355
+ ctx.lineTo(x + size, y + size);
356
+ ctx.lineTo(x, y - size);
357
+ }
358
+ ctx.closePath();
359
+ ctx.fill();
360
+ }
361
+
362
+ function sanitize(name) {
363
+ return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
364
+ }
365
+
366
+ // Picks the windowPositions sample closest in time to the trial's start
367
+ // anchor and translates it into the field shape chooseScreenFrame() expects.
368
+ //
369
+ // Returns null when:
370
+ // - windowPositions is empty (e.g. signal disabled, or pre-tracking data)
371
+ // - the trial has no trialStart_perfNow (no time anchor → can't pick)
372
+ //
373
+ // Translation: monitor stores compact field names (x/y/w/h/iw/ih/sw/sh) for
374
+ // terse JSON; the renderer's chooseScreenFrame reads expanded names. Doing
375
+ // the rename here keeps both sides clean.
376
+ //
377
+ // The screenWidth/screenHeight fields are conditionally included — the
378
+ // April 2026 monitor change added sw/sh capture, but older recordings
379
+ // won't have it. Omitting (rather than zero-filling) lets chooseScreenFrame
380
+ // fall through its legacy/auto-fit branches honestly.
381
+ export function pickWindowGeometryForTrial(trial, windowPositions) {
382
+ if (!Array.isArray(windowPositions) || windowPositions.length === 0) return null;
383
+ const anchor = trial?.trialStart_perfNow;
384
+ if (typeof anchor !== 'number') return null;
385
+
386
+ // Selection strategy (in priority order):
387
+ // 1. Among samples within the trial's time range, prefer ones whose
388
+ // viewport (after zoom-scaling) contains the trial's max mouse extent.
389
+ // This handles trials that span a window resize — the trial's mouse
390
+ // events may live in different viewport states, so we pick the one
391
+ // that best contains them visually. Closest-in-time among fitting
392
+ // candidates.
393
+ // 2. If no in-range sample fits, pick the in-range sample with the
394
+ // largest viewport (best chance of mouse showing inside outline).
395
+ // 3. If nothing in range at all, fall back to the global closest-in-time.
396
+ //
397
+ // Without this, a trial that spanned a resize would get an arbitrary
398
+ // snapshot and mouse plotted outside the outline — visually misleading.
399
+
400
+ const duration = trial.duration_ms || 0;
401
+ const trialEnd = anchor + duration;
402
+ const moves = (trial.mouseEvents || []).filter(e => e.type === 'move');
403
+ const maxX = moves.length ? Math.max(...moves.map(m => m.x)) : 0;
404
+ const maxY = moves.length ? Math.max(...moves.map(m => m.y)) : 0;
405
+
406
+ function fits(s) {
407
+ if (!s.w || !s.iw || !s.h || !s.ih) return false;
408
+ return (maxX * s.w / s.iw) <= s.w && (maxY * s.h / s.ih) <= s.h;
409
+ }
410
+ function viewportArea(s) { return (s.iw || 0) * (s.ih || 0); }
411
+ function timeDelta(s) { return Math.abs(s.t - anchor); }
412
+
413
+ // Allow a 1-second slop on each end of the trial to account for resize
414
+ // event debouncing (samples may land slightly outside the trial window).
415
+ const inRange = duration > 0
416
+ ? windowPositions.filter(s => s.t >= anchor - 1000 && s.t <= trialEnd + 1000)
417
+ : windowPositions;
418
+
419
+ let best;
420
+ const fitting = inRange.filter(fits);
421
+ if (fitting.length > 0) {
422
+ best = fitting.reduce((a, b) => timeDelta(a) <= timeDelta(b) ? a : b);
423
+ } else if (inRange.length > 0) {
424
+ best = inRange.reduce((a, b) => viewportArea(a) >= viewportArea(b) ? a : b);
425
+ } else {
426
+ best = windowPositions.reduce((a, b) => timeDelta(a) <= timeDelta(b) ? a : b);
427
+ }
428
+
429
+ const out = {
430
+ windowX: best.x,
431
+ windowY: best.y,
432
+ windowWidth: best.w,
433
+ windowHeight: best.h,
434
+ // Both outer and inner dimensions on BOTH axes are needed by
435
+ // computeZoomScale — chrome asymmetry means the X and Y zoom factors
436
+ // genuinely differ. Earlier versions of this function dropped the
437
+ // height fields, which silently zeroed out the Y scale and plotted
438
+ // mouse Y way outside the outline at non-100% zoom.
439
+ outerWidth: best.w,
440
+ outerHeight: best.h,
441
+ innerWidth: best.iw,
442
+ innerHeight: best.ih,
443
+ };
444
+ if (typeof best.sw === 'number' && typeof best.sh === 'number') {
445
+ out.screenWidth = best.sw;
446
+ out.screenHeight = best.sh;
447
+ }
448
+ // Zoom diagnostics added April 2026. Optional — older recordings won't
449
+ // have these fields. devicePixelRatio captures HIDPI/retina scaling;
450
+ // visualViewportScale captures pinch zoom (touch devices, distinct
451
+ // from CSS-level browser zoom which we infer from outerWidth/innerWidth).
452
+ if (typeof best.dpr === 'number') out.devicePixelRatio = best.dpr;
453
+ if (typeof best.vvScale === 'number') out.visualViewportScale = best.vvScale;
454
+ return out;
455
+ }
456
+
457
+ // CSS-level browser zoom (Cmd-Plus / Cmd-Minus on desktop) has no direct JS
458
+ // API. We infer it from the ratio of outer (window frame, fixed in screen
459
+ // pixels) to inner (viewport, in CSS pixels that scale with zoom):
460
+ // zoom_factor ≈ outerWidth / innerWidth
461
+ // - ≈ 1 at 100% zoom
462
+ // - < 1 zoomed out (viewport reports MORE CSS pixels than outer frame)
463
+ // - > 1 zoomed in (viewport reports FEWER CSS pixels than outer frame)
464
+ //
465
+ // Critically, X and Y zoom factors can differ because chrome geometry isn't
466
+ // symmetric: most browsers have ~0px left/right chrome but ~80px top chrome
467
+ // (tabs + address bar). On macOS retina especially, this asymmetry creates
468
+ // significantly different x and y ratios, so a single uniform scale plots
469
+ // mouse outside the outline on one axis. Returning {x, y} fixes this.
470
+ //
471
+ // Both fields default to 1 (identity, no scaling) when either dimension is
472
+ // missing or non-positive — keeps callers honest without per-call null checks.
473
+ export function computeZoomScale(metadata) {
474
+ if (!metadata) return { x: 1, y: 1 };
475
+ const ow = metadata.outerWidth, iw = metadata.innerWidth;
476
+ const oh = metadata.outerHeight, ih = metadata.innerHeight;
477
+ return {
478
+ x: (typeof ow === 'number' && typeof iw === 'number' && ow > 0 && iw > 0) ? ow / iw : 1,
479
+ y: (typeof oh === 'number' && typeof ih === 'number' && oh > 0 && ih > 0) ? oh / ih : 1,
480
+ };
481
+ }
482
+
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 }
487
+ // { mode: 'auto-fit' }
488
+ //
489
+ // The 'screen-api' branch trusts metadata.screens (Window Management API). It
490
+ // uses the screen the window currently sits on (currentScreenLabel-matched)
491
+ // rather than the primary monitor, eliminating the multi-monitor paradox.
492
+ //
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.
496
+ export function chooseScreenFrame(metadata) {
497
+ if (!metadata) return { mode: 'auto-fit' };
498
+
499
+ // Branch 1: Window Management API data present (post-P7 collection).
500
+ if (Array.isArray(metadata.screens) && metadata.screens.length > 0) {
501
+ // Pick the screen the window lives on. If currentScreenLabel matches a
502
+ // labeled entry, use it; otherwise prefer the screen whose bounds contain
503
+ // the window origin; otherwise fall back to the primary.
504
+ const current = pickCurrentScreen(metadata);
505
+ const winX = metadata.windowX ?? 0;
506
+ const winY = metadata.windowY ?? 0;
507
+ const winW = metadata.windowWidth || current.width;
508
+ const winH = metadata.windowHeight || current.height;
509
+ return {
510
+ mode: 'screen-api',
511
+ screenW: current.width,
512
+ screenH: current.height,
513
+ winX, winY, winW, winH,
514
+ drawOuter: true
515
+ };
516
+ }
517
+
518
+ // Branch 2: legacy data — primaryScreenWidth (post-P7 alias) or screenWidth.
519
+ const screenW = metadata.primaryScreenWidth ?? metadata.screenWidth;
520
+ const screenH = metadata.primaryScreenHeight ?? metadata.screenHeight;
521
+ if (screenW > 0 && screenH > 0) {
522
+ const winX = metadata.windowX ?? 0;
523
+ const winY = metadata.windowY ?? 0;
524
+ const winW = metadata.windowWidth || screenW;
525
+ const winH = metadata.windowHeight || screenH;
526
+ // Paradox check: window must fit inside screen for the outer rect to be
527
+ // honest. A 1px tolerance accommodates rounding. If the window exceeds
528
+ // the screen on any side, drop the outer rect — the participant likely
529
+ // zoomed out, used a secondary monitor, or has DPR scaling weirdness.
530
+ const tol = 1;
531
+ const fits =
532
+ winX >= -tol && winY >= -tol &&
533
+ (winX + winW) <= (screenW + tol) &&
534
+ (winY + winH) <= (screenH + tol);
535
+ return {
536
+ mode: 'legacy',
537
+ screenW, screenH, winX, winY, winW, winH,
538
+ drawOuter: fits
539
+ };
540
+ }
541
+
542
+ // Branch 3: window-only — we have window geometry but no screen size.
543
+ // Recordings made before the April 2026 monitor change (which added
544
+ // window.screen.width/height capture) hit this path. Draw just the
545
+ // browser-window rectangle; the screen rectangle is omitted because we
546
+ // genuinely don't know the screen dimensions.
547
+ if (metadata.windowWidth > 0 && metadata.windowHeight > 0) {
548
+ return {
549
+ mode: 'window-only',
550
+ winX: metadata.windowX ?? 0,
551
+ winY: metadata.windowY ?? 0,
552
+ winW: metadata.windowWidth,
553
+ winH: metadata.windowHeight,
554
+ drawOuter: false,
555
+ };
556
+ }
557
+
558
+ return { mode: 'auto-fit' };
559
+ }
560
+
561
+ function pickCurrentScreen(metadata) {
562
+ const screens = metadata.screens;
563
+ if (metadata.currentScreenLabel) {
564
+ const match = screens.find(s => s.label === metadata.currentScreenLabel);
565
+ if (match) return match;
566
+ }
567
+ // Prefer the screen whose availLeft/availTop bound the window origin.
568
+ if (typeof metadata.windowX === 'number' && typeof metadata.windowY === 'number') {
569
+ const containing = screens.find(s =>
570
+ metadata.windowX >= (s.availLeft ?? 0) &&
571
+ metadata.windowX < (s.availLeft ?? 0) + s.width &&
572
+ metadata.windowY >= (s.availTop ?? 0) &&
573
+ metadata.windowY < (s.availTop ?? 0) + s.height
574
+ );
575
+ if (containing) return containing;
576
+ }
577
+ return screens.find(s => s.isPrimary) || screens[0];
578
+ }
579
+
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.
586
+ export function computeZoomTag(metadata) {
587
+ if (!metadata) return null;
588
+ const tol = 0.05;
589
+ const vvScale = metadata.visualViewportScale;
590
+ if (typeof vvScale === 'number' && Math.abs(vvScale - 1) > tol) {
591
+ return `zoom × ${vvScale.toFixed(2)}`;
592
+ }
593
+ const ow = metadata.outerWidth;
594
+ const iw = metadata.windowWidth ?? metadata.innerWidth;
595
+ if (typeof ow === 'number' && typeof iw === 'number' && iw > 0) {
596
+ const ratio = ow / iw;
597
+ // Larger outer/inner means user zoomed out (viewport > chrome).
598
+ // Smaller means zoomed in. Either way, flag deviation.
599
+ 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
+ const zoomEstimate = 1 / ratio;
603
+ return `zoom × ${zoomEstimate.toFixed(2)}`;
604
+ }
605
+ }
606
+ return null;
607
+ }
@@ -0,0 +1,29 @@
1
+ // src/cli/renderers/triage-md.js
2
+ // Writes triage.md — a ranked markdown table of participants by suspiciousness.
3
+ // This is the "start here" document for manual review.
4
+
5
+ import { writeFileSync } from 'fs';
6
+ import { join } from 'path';
7
+
8
+ export async function renderTriage(triage, config) {
9
+ const lines = [
10
+ '# Participant Triage — Ranked by Suspiciousness',
11
+ '',
12
+ `_${triage.length} participants analyzed_`,
13
+ '',
14
+ '| Rank | Participant | Score | Hard | Reason |',
15
+ '|------|-------------|-------|------|--------|',
16
+ ];
17
+
18
+ triage.forEach((t, i) => {
19
+ const hard = t.hardTriggered ? '**YES**' : 'no';
20
+ // Escape pipe characters in reason text to avoid breaking the table
21
+ const reason = t.reason.replace(/\|/g, '\\|');
22
+ lines.push(`| ${i + 1} | ${t.participantId} | ${t.score} | ${hard} | ${reason} |`);
23
+ });
24
+
25
+ lines.push('');
26
+ const outPath = join(config.outputDir, 'triage.md');
27
+ writeFileSync(outPath, lines.join('\n'));
28
+ console.log(` triage.md — ranked list`);
29
+ }