cyborg-hunter 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/CITATION.cff +2 -2
  3. package/README.md +5 -3
  4. package/dist/cyborg-hunter-replay.js +2 -2
  5. package/dist/cyborg-hunter.esm.js +1 -1
  6. package/dist/cyborg-hunter.min.js +2 -2
  7. package/dist/extension-cyborg-hunter.js +1 -1
  8. package/package.json +1 -1
  9. package/src/cli/preview-entry.js +6 -0
  10. package/src/cli/renderers/font-face-css.js +16 -0
  11. package/src/cli/renderers/fonts/FONTS_MANIFEST.json +69 -0
  12. package/src/cli/renderers/fonts/majormonodisplay/OFL.txt +93 -0
  13. package/src/cli/renderers/fonts/majormonodisplay/majormonodisplay-400.woff2 +0 -0
  14. package/src/cli/renderers/fonts/recursive/OFL.txt +93 -0
  15. package/src/cli/renderers/fonts/recursive/recursive-300-1000.woff2 +0 -0
  16. package/src/cli/renderers/fonts/sofiasans/OFL.txt +93 -0
  17. package/src/cli/renderers/fonts/sofiasans/sofiasans-1-1000.woff2 +0 -0
  18. package/src/cli/renderers/fonts/sora/OFL.txt +93 -0
  19. package/src/cli/renderers/fonts/sora/sora-100-800.woff2 +0 -0
  20. package/src/cli/renderers/fonts/spacegrotesk/OFL.txt +93 -0
  21. package/src/cli/renderers/fonts/spacegrotesk/spacegrotesk-300-700.woff2 +0 -0
  22. package/src/cli/renderers/fonts/tomorrow/OFL.txt +93 -0
  23. package/src/cli/renderers/fonts/tomorrow/tomorrow-400.woff2 +0 -0
  24. package/src/cli/renderers/html-index-core.js +98 -96
  25. package/src/cli/renderers/html-index.js +7 -2
  26. package/src/cli/renderers/replay-styles.js +61 -0
  27. package/src/cli/renderers/replay-viewer.client.js +8 -6
  28. package/src/cli/renderers/report-fonts.js +24 -0
  29. package/src/jspsych/extension-cyborg-hunter-replay.js +1 -1
  30. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  31. package/src/shared/constants.js +1 -1
@@ -22,6 +22,7 @@
22
22
  import { VERSION, sanitizeId } from '../../shared/constants.js';
23
23
  import { decomposeScore } from '../analyzers/triage.js';
24
24
  import { resolveScoreWeights, customWeightsText, formatScore } from '../analyzers/score-weights.js';
25
+ import { REPLAY_STYLES_CSS } from './replay-styles.js';
25
26
  import { getByPath } from '../../shared/paths.js';
26
27
  import { inferTier } from '../../replay/viewer-model.js';
27
28
  import { inlineSafeJson, inlineSafeSrc } from '../../shared/inline-safe.js';
@@ -44,6 +45,10 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
44
45
  // was hand-rolled in nine places and missing from exactly this one, which is
45
46
  // why the rule now lives in one module (T5 Task 10 + its fix round 3).
46
47
  const replayClientSrc = inlineSafeSrc(opts.replayClientSrc);
48
+ // @font-face rules for the report's typefaces (report-fonts.js builds them
49
+ // as base64 data URIs for the CLI). Absent → no faces, and every role
50
+ // renders in its fallback stack (the demo bundle and the snapshot tests).
51
+ const fontFaceCss = opts.fontFaceCss ? String(opts.fontFaceCss) + '\n' : '';
47
52
  const visualsUnavailableNote = opts.visualsUnavailableNote
48
53
  ?? 'Visual renderers not available (install the canvas package).';
49
54
  const imageSources = opts.imageSources ?? null; // pid → {typingProfile, sessionTimeline, trajectories} data URIs (null entry = omit that img)
@@ -105,28 +110,41 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
105
110
  <meta name="viewport" content="width=device-width, initial-scale=1">
106
111
  <title>Cyborg Hunter Report</title>
107
112
  <style>
108
- :root {
109
- --bg: #fafafa; --surface: #fff; --ink: #1a1814;
110
- --dim: #706a5c; --line: #e2ddd1;
113
+ ${fontFaceCss} :root {
114
+ --bg: #fafafa; --surface: #fff; --ink: #0f0f0f;
115
+ --dim: #4f4a40; --line: #b9b2a2;
111
116
  --hard: #d32f2f; --soft: #f57c00; --clean: #388e3c;
117
+ /* Typefaces (embedded by report-fonts.js; picked in the 2026-09 report
118
+ palette). Each stack falls back to the system face when a font is
119
+ absent (demo bundle) or lacks a glyph (≥ ≤ ▶ ⓘ). */
120
+ --ff-space: "Space Grotesk", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
121
+ --ff-tomorrow: "Tomorrow", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
122
+ --ff-sofia: "Sofia Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
123
+ --ff-sora: "Sora", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
124
+ --ff-recursive: "Recursive", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
125
+ --ff-majormono: "Major Mono Display", ui-monospace, SFMono-Regular, monospace;
112
126
  }
113
127
  * { box-sizing: border-box; margin: 0; padding: 0; }
114
128
  html, body { height: 100%; }
115
129
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
116
130
  color: var(--ink); background: var(--bg); display: grid; grid-template-rows: auto 1fr; height: 100vh; }
117
131
  .topbar { display: flex; align-items: center; gap: 16px; padding: 12px 20px;
118
- background: var(--surface); border-bottom: 1px solid var(--line); }
119
- .topbar h1 { font-size: 18px; font-weight: 600; }
120
- .topbar .meta { color: var(--dim); font-size: 13px; flex: 1; }
132
+ background: var(--surface); border-bottom: 3px double var(--ink); }
133
+ .topbar h1 { font-family: var(--ff-space); font-size: 32.4px; font-weight: 550; letter-spacing: 0.01em; }
134
+ .topbar .meta { font-family: var(--ff-tomorrow); color: var(--dim); font-size: 13px; flex: 1; }
121
135
  .topbar button { padding: 6px 12px; border: 1px solid var(--line); background: var(--surface);
122
- color: var(--ink); border-radius: 4px; cursor: pointer; font-size: 13px; }
136
+ color: var(--ink); border-radius: 4px; cursor: pointer;
137
+ font-family: var(--ff-recursive); font-size: 19.5px; font-weight: 400; letter-spacing: 0; }
123
138
  .topbar button:hover { background: var(--bg); }
124
139
  .layout { display: grid; grid-template-columns: 360px 1fr; height: 100%; overflow: hidden; }
125
140
  .rail { background: var(--surface); border-right: 1px solid var(--line); overflow-y: auto; }
126
141
  .detail { overflow-y: auto; padding: 20px; }
127
- .note { color: var(--dim); font-style: italic; font-size: 13px; }
142
+ .note { font-family: var(--ff-tomorrow); color: var(--dim); font-style: italic; font-size: 13px; }
143
+ /* .mono marks data values (ids, scores, counts, timestamps). It was a
144
+ system monospace; it now defaults to Sora with tabular figures, and the
145
+ roles below override it (timestamps, evidence text). */
128
146
  .mono {
129
- font: 13px/1.3 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
147
+ font: 13px/1.3 var(--ff-sora);
130
148
  font-variant-numeric: tabular-nums;
131
149
  }
132
150
  [hidden] { display: none !important; }
@@ -142,34 +160,43 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
142
160
  padding: 10px 12px; border-top: 1px solid var(--line);
143
161
  background: var(--surface); font-size: 12px;
144
162
  }
145
- .totals-title { color: var(--dim); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; }
146
- .totals-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; }
147
- .totals-row .count { margin-left: auto; }
163
+ .totals-title { font-family: var(--ff-sora); color: var(--dim); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; }
164
+ .totals-row { font-family: var(--ff-sora); display: flex; align-items: center; gap: 6px; padding: 2px 0; }
165
+ .totals-row .count { font-family: var(--ff-sora); margin-left: auto; }
148
166
 
149
167
  /* Search */
150
168
  .search-wrap input {
151
169
  width: 100%; padding: 6px 8px; border: 1px solid var(--line);
152
- border-radius: 4px; font-size: 13px; background: var(--bg);
170
+ border-radius: 4px; background: var(--bg);
153
171
  color: var(--ink);
172
+ font-family: var(--ff-recursive); font-size: 19.5px; font-weight: 400; letter-spacing: 0;
154
173
  }
155
174
  .search-wrap input:focus { outline: 1px solid var(--ink); border-color: var(--ink); }
156
175
 
157
- /* Filter chips */
158
- .filter-chips { display: flex; flex-wrap: wrap; gap: 4px; }
176
+ /* Filter chips — one segmented control: joined buttons, square inner
177
+ edges, outer corners 4px. 15px with 4px 8px padding keeps four 3-digit
178
+ counts on one line in the 360px rail (measured 2026-09-30). */
179
+ .filter-chips { display: flex; flex-wrap: wrap; gap: 0; }
159
180
  .filter-chip {
160
- padding: 3px 8px; border: 1px solid var(--line); background: var(--surface);
161
- color: var(--ink); border-radius: 12px; cursor: pointer; font-size: 12px;
181
+ padding: 4px 8px; border: 1px solid var(--line); background: var(--surface);
182
+ color: var(--ink); border-radius: 0; margin-left: -1px; cursor: pointer;
183
+ font-family: var(--ff-recursive); font-size: 15px; font-weight: 400; letter-spacing: 0;
162
184
  }
185
+ .filter-chip:first-child { border-radius: 4px 0 0 4px; margin-left: 0; }
186
+ .filter-chip:last-child { border-radius: 0 4px 4px 0; }
163
187
  .filter-chip:hover { background: var(--bg); }
188
+ /* Raised so the next chip's -1px overlap can't paint over its ink edge. */
164
189
  .filter-chip.active {
165
190
  background: var(--ink); color: var(--surface); border-color: var(--ink);
191
+ position: relative; z-index: 1;
166
192
  }
167
193
 
168
194
  /* Sort selector */
169
- .sort-wrap { font-size: 12px; color: var(--dim); }
195
+ .sort-wrap { font-family: var(--ff-tomorrow); font-size: 12px; color: var(--dim); }
170
196
  .sort-wrap select {
171
197
  background: var(--surface); color: var(--ink); border: 1px solid var(--line);
172
- border-radius: 4px; padding: 2px 4px; font-size: 12px;
198
+ border-radius: 4px; padding: 2px 4px;
199
+ font-family: var(--ff-recursive); font-size: 18px; font-weight: 400; letter-spacing: 0;
173
200
  }
174
201
 
175
202
  /* Cohort rows */
@@ -178,23 +205,27 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
178
205
  cursor: pointer;
179
206
  }
180
207
  .cohort-row:hover { background: var(--bg); }
208
+ /* Selected row: a 2px inset ink outline (no layout shift, row tone kept). */
181
209
  .cohort-row.selected {
182
- border-left: 4px solid var(--ink);
183
- padding-left: 8px; /* compensate for the 4px left border */
184
- background: var(--bg);
210
+ border-left: 0;
211
+ padding-left: 12px;
212
+ background: transparent;
213
+ box-shadow: inset 0 0 0 2px var(--ink);
185
214
  }
186
215
  .cohort-row-top { display: flex; align-items: center; gap: 8px; }
187
216
  .cohort-row-top .pid {
217
+ font-family: var(--ff-sora);
188
218
  flex: 1; min-width: 0; color: var(--ink);
189
219
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
190
220
  }
191
- .cohort-row-top .score { color: var(--dim); flex-shrink: 0; }
221
+ .cohort-row-top .score { font-family: var(--ff-sora); color: var(--dim); flex-shrink: 0; }
192
222
  .cohort-row.selected .cohort-row-top .score { color: var(--ink); font-weight: 600; }
193
223
  .cohort-row-bot {
194
224
  display: flex; align-items: flex-start; gap: 8px;
195
225
  margin-top: 4px;
196
226
  }
197
227
  .reason-excerpt {
228
+ font-family: var(--ff-recursive);
198
229
  flex: 1; color: var(--dim); font-size: 12px;
199
230
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
200
231
  overflow: hidden; text-overflow: ellipsis;
@@ -209,9 +240,10 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
209
240
  .tier-dot[data-tier="soft"] { background: var(--soft); }
210
241
  .tier-dot[data-tier="clean"] { background: var(--clean); }
211
242
 
212
- /* Tier badge — small pill, filled for hard/soft, outline for clean */
243
+ /* Tier badge — small square tag, filled for hard/soft, outline for clean */
213
244
  .tier-badge {
214
- font-size: 10px; padding: 2px 6px; border-radius: 3px;
245
+ font-family: var(--ff-sora);
246
+ font-size: 10px; padding: 2px 6px; border-radius: 0;
215
247
  font-weight: 600; letter-spacing: 0.04em; flex-shrink: 0;
216
248
  }
217
249
  .tier-badge[data-tier="hard"] { background: var(--hard); color: white; }
@@ -245,17 +277,21 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
245
277
 
246
278
  /* Header strip */
247
279
  .detail-header {
248
- border-bottom: 1px solid var(--line);
280
+ border-bottom: 1px solid var(--ink);
249
281
  padding-bottom: 12px; margin-bottom: 16px;
250
282
  }
251
283
  .detail-header-top { display: flex; align-items: center; gap: 12px; }
252
- .detail-header-top .pid-full { flex: 1; font-size: 13px; color: var(--ink); }
253
- .detail-header-top .score-big { font-size: 36px; font-weight: 600; color: var(--ink); }
254
- .detail-header-sub { color: var(--dim); font-size: 12px; margin-top: 4px; }
255
-
256
- /* Tier pill — bigger version of the tier badge in the rail */
284
+ .detail-header-top .pid-full { font-family: var(--ff-sora); flex: 1; font-size: 13px; color: var(--ink); }
285
+ .detail-header-top .score-big { font-family: var(--ff-sora); font-size: 36px; font-weight: 600; color: var(--ink); }
286
+ .detail-header-sub { font-family: var(--ff-tomorrow); color: var(--dim); font-size: 12px; margin-top: 4px; }
287
+ /* The optional platform-ID line is an identifier, so it stays in the ID
288
+ face rather than taking the meta-line face by source order. */
289
+ .detail-header-sub.mono { font-family: var(--ff-sora); }
290
+
291
+ /* Tier pill — bigger version of the tier badge in the rail (square tag) */
257
292
  .tier-pill {
258
- font-size: 11px; padding: 3px 8px; border-radius: 4px;
293
+ font-family: var(--ff-sora);
294
+ font-size: 11px; padding: 3px 8px; border-radius: 0;
259
295
  font-weight: 600; letter-spacing: 0.04em;
260
296
  }
261
297
  .tier-pill[data-tier="hard"] { background: var(--hard); color: white; }
@@ -276,7 +312,7 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
276
312
  }
277
313
  .signal-tile {
278
314
  border: 1px solid var(--line);
279
- border-radius: 4px;
315
+ border-radius: 0;
280
316
  padding: 8px 10px;
281
317
  display: flex;
282
318
  flex-direction: column;
@@ -290,8 +326,8 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
290
326
  .signal-tile.tone-warn { color: var(--soft); border-color: var(--soft); background: rgba(245, 124, 0, 0.04); }
291
327
  .signal-tile.tone-muted { color: var(--ink); border-color: var(--ink); background: rgba(26, 24, 20, 0.03); }
292
328
  .signal-tile.tone-zero { color: #b8b1a0; border-color: #ebe6d8; background: transparent; }
293
- .signal-value { font-size: 18px; font-weight: 700; line-height: 1; }
294
- .signal-label { font-size: 10px; letter-spacing: 0.4px; text-transform: uppercase; opacity: 0.85; }
329
+ .signal-value { font-family: var(--ff-sora); font-size: 18px; font-weight: 700; line-height: 1; }
330
+ .signal-label { font-family: var(--ff-recursive); font-size: 10px; letter-spacing: 0.4px; text-transform: uppercase; opacity: 0.85; }
295
331
 
296
332
  /* Score breakdown — horizontal flex of weighted contributions to t.score,
297
333
  ending in "Total: N". Only non-zero terms render; the bar widths are
@@ -304,21 +340,22 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
304
340
  display: inline-flex; gap: 6px; align-items: center;
305
341
  font-size: 12px;
306
342
  }
307
- .score-term .label { color: var(--dim); }
308
- .score-term .contrib { color: var(--ink); }
343
+ .score-term .label { font-family: var(--ff-recursive); color: var(--dim); }
344
+ .score-term .contrib { font-family: var(--ff-sora); color: var(--ink); }
309
345
  .score-term .bar {
310
- height: 6px; background: var(--ink); opacity: 0.4; border-radius: 3px;
346
+ height: 6px; background: var(--ink); opacity: 0.4; border-radius: 2px;
311
347
  }
312
- .score-total { margin-left: auto; font-weight: 600; color: var(--ink); }
348
+ .score-total { font-family: var(--ff-sora); margin-left: auto; font-weight: 600; color: var(--ink); }
313
349
 
314
- /* Reason pull-quote — warm-tinted block-quote with a left rule. The bg
315
- color is picked by hand from --bg's family rather than computed, so
316
- it shifts cleanly when the palette changes. */
350
+ /* Reason note — a hairline box on the surface (the earlier warm tint and
351
+ 3px left rule were dropped in the 2026-09 reshape). */
317
352
  .reason {
318
- border-left: 3px solid var(--ink);
319
- background: #f5f1e8;
353
+ background: var(--surface);
354
+ border: 1px solid var(--line);
355
+ border-radius: 4px;
320
356
  padding: 10px 14px;
321
357
  margin: 0 0 16px;
358
+ font-family: var(--ff-tomorrow);
322
359
  font-size: 14px;
323
360
  color: var(--ink);
324
361
  }
@@ -326,6 +363,7 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
326
363
  /* Session-level signals — small cards in a responsive grid. The grid
327
364
  collapses from 3 cells to 1 in narrow detail panes via auto-fit. */
328
365
  .section-heading {
366
+ font-family: var(--ff-sofia);
329
367
  font-size: 13px; color: var(--dim);
330
368
  text-transform: uppercase; letter-spacing: 0.04em;
331
369
  margin: 18px 0 8px;
@@ -337,14 +375,16 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
337
375
  }
338
376
  .sig-cell {
339
377
  background: var(--surface); border: 1px solid var(--line);
340
- border-radius: 6px; padding: 10px;
378
+ border-radius: 0; padding: 10px;
379
+ box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.05);
341
380
  }
342
381
  .sig-cell-title {
382
+ font-family: var(--ff-sofia);
343
383
  font-size: 11px; color: var(--dim);
344
384
  text-transform: uppercase; letter-spacing: 0.04em;
345
385
  }
346
386
  .sig-cell ul { list-style: none; margin-top: 4px; font-size: 12px; padding: 0; }
347
- .sig-cell li { padding: 2px 0; word-break: break-word; }
387
+ .sig-cell li { font-family: var(--ff-recursive); padding: 2px 0; word-break: break-word; }
348
388
  .muted { color: var(--dim); }
349
389
 
350
390
  /* Paste evidence — list of expandable entries */
@@ -359,10 +399,11 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
359
399
  }
360
400
  .paste-toggle:disabled { cursor: default; }
361
401
  .paste-toggle:hover:not(:disabled) { color: var(--ink); }
362
- .paste-trial { color: var(--dim); flex-shrink: 0; }
402
+ .paste-trial { font-family: var(--ff-majormono); color: var(--dim); flex-shrink: 0; }
363
403
  .paste-preview, .paste-full {
364
- background: #f5f1e8; /* warm tinted bg, same as the reason pull-quote */
365
- padding: 4px 8px; border-radius: 3px;
404
+ background: var(--surface); border: 1px solid var(--line); /* hairline box, like .reason */
405
+ padding: 4px 8px; border-radius: 0;
406
+ font-family: var(--ff-tomorrow);
366
407
  font-size: 12px;
367
408
  word-break: break-word; white-space: pre-wrap;
368
409
  flex: 1; min-width: 0; /* allow flex item to shrink and wrap correctly */
@@ -370,7 +411,7 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
370
411
  /* When expanded by Task 11's JS: hide preview, show full text */
371
412
  .paste-entry.expanded .paste-preview { display: none; }
372
413
  .paste-entry:not(.expanded) .paste-full { display: none; }
373
- .paste-overflow { font-size: 12px; margin-top: 4px; }
414
+ .paste-overflow { font-family: var(--ff-tomorrow); font-size: 12px; margin-top: 4px; }
374
415
 
375
416
  /* Image blocks — wrapper containers so a missing PNG hides its heading too.
376
417
  Each .image-block contains a <h4 class="section-heading"> + a zoomable
@@ -383,7 +424,8 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
383
424
  .image-block .section-heading { margin: 0 0 6px; }
384
425
  .detail img {
385
426
  max-width: 100%; display: block; margin: 6px 0 0;
386
- border: 1px solid var(--line); border-radius: 4px;
427
+ border: 1px solid var(--line); border-radius: 0;
428
+ box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.05);
387
429
  }
388
430
  .zoomable {
389
431
  display: block; cursor: zoom-in;
@@ -423,7 +465,7 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
423
465
  max-width: 560px; max-height: calc(100vh - 16vh);
424
466
  margin: 8vh auto 0;
425
467
  padding: 20px 24px;
426
- border-radius: 8px;
468
+ border-radius: 0;
427
469
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
428
470
  overflow-y: auto;
429
471
  }
@@ -436,6 +478,7 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
436
478
  }
437
479
  .modal-close:hover { color: var(--ink); }
438
480
  .modal-card h3 {
481
+ font-family: var(--ff-space);
439
482
  font-size: 16px; margin-bottom: 12px;
440
483
  }
441
484
  .legend-table {
@@ -447,7 +490,9 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
447
490
  border-bottom: 1px solid var(--line);
448
491
  vertical-align: top;
449
492
  }
493
+ .legend-table td { font-family: var(--ff-recursive); }
450
494
  .legend-table th {
495
+ font-family: var(--ff-sofia);
451
496
  color: var(--dim); font-weight: 500; font-size: 12px;
452
497
  text-transform: uppercase; letter-spacing: 0.04em;
453
498
  }
@@ -729,50 +774,7 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
729
774
  })();
730
775
  </script>
731
776
  <style>
732
- /* Replay viewer (see replay-viewer.client.js) — matches report house style */
733
- .replay-header { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 8px 0; }
734
- .replay-badge { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px;
735
- background: var(--ink); color: var(--surface); }
736
- .replay-badge[data-tier="trace"] { background: var(--surface); color: var(--ink);
737
- border: 1px solid var(--line); }
738
- .replay-stage { position: relative; overflow: hidden; background: var(--surface);
739
- border: 1px solid var(--line); border-radius: 4px; }
740
- .replay-frame { position: absolute; top: 0; left: 0; border: 0; }
741
- .replay-overlay { position: absolute; top: 0; left: 0; pointer-events: none; }
742
- .replay-neutral { background: #e8e6e0; }
743
- .replay-neutral-label { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
744
- color: var(--dim); font-size: 13px; }
745
- .replay-lane { display: block; margin-top: 6px; border-radius: 2px; }
746
- .replay-scrub { display: block; margin: 2px 0 4px; }
747
- .replay-ticker { font: 12px/1.3 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
748
- color: var(--dim); font-variant-numeric: tabular-nums;
749
- height: 1.4em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
750
- .replay-note { font-size: 12px; color: var(--dim); }
751
- .replay-warn { color: var(--hard); }
752
- /* Controls share the report's flat, line-bordered button style */
753
- .replay-play, .replay-load-btn, .replay-css-btn, .replay-segment-select, .replay-speed {
754
- padding: 4px 10px; border: 1px solid var(--line); background: var(--surface);
755
- color: var(--ink); border-radius: 4px; cursor: pointer; font-size: 13px; }
756
- .replay-play:hover, .replay-load-btn:hover, .replay-css-btn:hover { background: var(--bg); }
757
- .replay-play:disabled, .replay-load-btn:disabled { opacity: 0.6; cursor: default; }
758
- .replay-fetch-css-label { margin-left: 10px; font-size: 12px; color: #555; }
759
- .replay-unstyled { position: absolute; left: 0; right: 0; top: 0; z-index: 3; background: #fff4dd; color: #7a4b00; border-bottom: 1px solid #e8b24a; padding: 6px 10px; font-size: 12px; line-height: 1.4; }
760
- .replay-css-btn { font-size: 12px; padding: 2px 8px; }
761
- .replay-clock { font: 12px/1.3 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
762
- font-variant-numeric: tabular-nums; }
763
- .replay-keycast { position: absolute; left: 0; right: 0; bottom: 0; display: flex;
764
- gap: 4px; padding: 5px 6px; pointer-events: none; flex-wrap: wrap-reverse; }
765
- .replay-key-chip { font: 11px/1.2 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
766
- background: rgba(0,0,0,0.72); color: #fff; padding: 2px 7px;
767
- border-radius: 3px; white-space: nowrap; }
768
- .replay-key-chip--redacted { background: rgba(0,0,0,0.5); font-style: italic; }
769
- /* Media is reported as state, never played (design §7): the badges say what
770
- the recorded element was doing, in the stage's top-left. */
771
- .replay-media { position: absolute; left: 0; top: 0; display: flex; gap: 4px;
772
- padding: 5px 6px; pointer-events: none; flex-wrap: wrap; }
773
- .replay-media-badge { font: 11px/1.2 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
774
- background: rgba(0,137,123,0.85); color: #fff; padding: 2px 7px;
775
- border-radius: 3px; white-space: nowrap; }
777
+ ${REPLAY_STYLES_CSS}
776
778
  </style>
777
779
  ${preloadedReplayScript}<script>
778
780
  ${replayClientSrc}
@@ -1,11 +1,13 @@
1
1
  // src/cli/renderers/html-index.js
2
2
  // Thin Node wrapper around the pure render core (html-index-core.js):
3
- // reads the replay viewer client from disk, writes index.html to outputDir.
3
+ // reads the replay viewer client and the embedded fonts from disk, writes
4
+ // index.html to outputDir.
4
5
  // Public API unchanged — report.js and adopters keep calling renderHtmlIndex.
5
6
  import { writeFileSync } from 'fs';
6
7
  import { join } from 'path';
7
8
  import { renderIndexHtml } from './html-index-core.js';
8
9
  import { readReplayClientSrc } from './replay-client-source.js';
10
+ import { buildFontFaceCss } from './report-fonts.js';
9
11
 
10
12
  // The replay viewer client is developed as real JS files (linted, syntax-
11
13
  // highlighted) and embedded verbatim at render time — same file://-safe
@@ -13,10 +15,13 @@ import { readReplayClientSrc } from './replay-client-source.js';
13
15
  // CONCATENATION: the client calls into src/replay/dom-instantiate.js, which
14
16
  // ships ahead of it in the same script (see replay-client-source.js).
15
17
  const REPLAY_CLIENT_SRC = readReplayClientSrc();
18
+ // The report's six typefaces as base64 @font-face rules (report-fonts.js),
19
+ // built once per process like the replay client.
20
+ const FONT_FACE_CSS = buildFontFaceCss();
16
21
 
17
22
  export async function renderHtmlIndex(summaries, triage, participants, config, visualsRendered) {
18
23
  const html = await renderIndexHtml(summaries, triage, participants, config,
19
- visualsRendered, { replayClientSrc: REPLAY_CLIENT_SRC });
24
+ visualsRendered, { replayClientSrc: REPLAY_CLIENT_SRC, fontFaceCss: FONT_FACE_CSS });
20
25
  writeFileSync(join(config.outputDir, 'index.html'), html);
21
26
  console.log(' index.html — report page');
22
27
  }
@@ -0,0 +1,61 @@
1
+ // src/cli/renderers/replay-styles.js
2
+ // The replay viewer's CSS (.replay-* rules), shared by the CLI report
3
+ // (html-index-core.js puts it in the report's second <style>) and the demo's
4
+ // replay-host iframe (demo/replay-host.js, which receives it from results.js
5
+ // through the preview-core bundle). One copy: the demo used to keep a
6
+ // hand-synced duplicate, which had drifted. Rules reference the report's
7
+ // tokens (--ink, --surface, --line, --bg, --dim, --hard and the --ff-* font
8
+ // stacks); a host document must declare them.
9
+
10
+ export const REPLAY_STYLES_CSS = ` /* Replay viewer (see replay-viewer.client.js) — matches report house style */
11
+ .replay-header { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 8px 0; }
12
+ .replay-badge { font-family: var(--ff-sora); font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 0;
13
+ background: var(--ink); color: var(--surface); }
14
+ .replay-badge[data-tier="trace"] { background: var(--surface); color: var(--ink);
15
+ border: 1px solid var(--line); }
16
+ .replay-stage { position: relative; overflow: hidden; background: var(--surface);
17
+ border: 1px solid var(--line); border-radius: 0; }
18
+ .replay-frame { position: absolute; top: 0; left: 0; border: 0; }
19
+ .replay-overlay { position: absolute; top: 0; left: 0; pointer-events: none; }
20
+ .replay-neutral { background: #e8e6e0; }
21
+ .replay-neutral-label { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
22
+ font-family: var(--ff-recursive); color: var(--dim); font-size: 13px; }
23
+ .replay-lane { display: block; margin-top: 6px; border-radius: 2px; }
24
+ .replay-scrub { display: block; margin: 2px 0 4px; }
25
+ .replay-ticker { font: 12px/1.3 var(--ff-majormono);
26
+ color: var(--dim); font-variant-numeric: tabular-nums;
27
+ height: 1.4em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
28
+ .replay-note { font-family: var(--ff-recursive); font-size: 12px; color: var(--dim); }
29
+ .replay-warn { color: var(--hard); }
30
+ .replay-pause-toggle { font-family: var(--ff-recursive); }
31
+ /* Controls share the report's flat, line-bordered button style */
32
+ .replay-play, .replay-load-btn, .replay-css-btn, .replay-segment-select, .replay-speed {
33
+ padding: 4px 10px; border: 1px solid var(--line); background: var(--surface);
34
+ color: var(--ink); border-radius: 4px; cursor: pointer;
35
+ font-family: var(--ff-recursive); font-size: 13px; }
36
+ /* Play is the transport's primary action: a round ink button. Its hover
37
+ is its own (darker ink); the light shared hover below would leave a
38
+ white ▶ on a light ground. */
39
+ .replay-play { background: var(--ink); color: var(--surface); border-color: var(--ink);
40
+ width: 36px; height: 30px; padding: 0; border-radius: 50%; }
41
+ .replay-play:hover { background: #2b2b2b; }
42
+ .replay-load-btn:hover, .replay-css-btn:hover { background: var(--bg); }
43
+ .replay-play:disabled, .replay-load-btn:disabled { opacity: 0.6; cursor: default; }
44
+ .replay-fetch-css-label { font-family: var(--ff-recursive); margin-left: 10px; font-size: 12px; color: #555; }
45
+ .replay-unstyled { position: absolute; left: 0; right: 0; top: 0; z-index: 3; background: #fff4dd; color: #7a4b00; border-bottom: 1px solid #e8b24a; padding: 6px 10px; font-family: var(--ff-recursive); font-size: 12px; line-height: 1.4; }
46
+ .replay-css-btn { font-size: 12px; padding: 2px 8px; }
47
+ .replay-clock { font: 12px/1.3 var(--ff-majormono);
48
+ font-variant-numeric: tabular-nums; }
49
+ .replay-keycast { position: absolute; left: 0; right: 0; bottom: 0; display: flex;
50
+ gap: 4px; padding: 5px 6px; pointer-events: none; flex-wrap: wrap-reverse; }
51
+ .replay-key-chip { font: 400 13px/1.2 var(--ff-tomorrow);
52
+ background: rgba(0,0,0,0.72); color: #fff; padding: 3px 9px;
53
+ border-radius: 2px; white-space: nowrap; }
54
+ .replay-key-chip--redacted { background: rgba(0,0,0,0.5); font-style: italic; }
55
+ /* Media is reported as state, never played (design §7): the badges say what
56
+ the recorded element was doing, in the stage's top-left. */
57
+ .replay-media { position: absolute; left: 0; top: 0; display: flex; gap: 4px;
58
+ padding: 5px 6px; pointer-events: none; flex-wrap: wrap; }
59
+ .replay-media-badge { font: 11px/1.2 var(--ff-tomorrow);
60
+ background: rgba(0,137,123,0.85); color: #fff; padding: 2px 7px;
61
+ border-radius: 3px; white-space: nowrap; }`;
@@ -1881,16 +1881,18 @@
1881
1881
  if (e.t >= playhead - 600) ripples.push(q);
1882
1882
  }
1883
1883
  }
1884
- // Cursor trail: fading polyline per segment
1884
+ // Cursor trail: breadcrumb dots, one per sampled point (after a
1885
+ // segment's first), fading with age. Gaps between dots show pointer
1886
+ // speed; a severed segment simply has no dot bridging the break. Drawn
1887
+ // FIRST, and never at radius 2, 5 or 7: the alignment battery reads the
1888
+ // cursor glyph off the final arcs (r=5 confident, r=7 then r=2 uncertain).
1885
1889
  segs.forEach(function (line) {
1886
1890
  for (var s = 1; s < line.length; s++) {
1887
1891
  var age = (playhead - line[s].t) / 2500;
1888
- ctx.strokeStyle = 'rgba(211,47,47,' + (0.85 * (1 - age)).toFixed(3) + ')';
1889
- ctx.lineWidth = 2;
1892
+ ctx.fillStyle = 'rgba(211,47,47,' + (0.85 * (1 - age)).toFixed(3) + ')';
1890
1893
  ctx.beginPath();
1891
- ctx.moveTo(line[s - 1].x, line[s - 1].y);
1892
- ctx.lineTo(line[s].x, line[s].y);
1893
- ctx.stroke();
1894
+ ctx.arc(line[s].x, line[s].y, 2.2, 0, Math.PI * 2);
1895
+ ctx.fill();
1894
1896
  }
1895
1897
  });
1896
1898
  // Click ripples: expanding rings over 600ms
@@ -0,0 +1,24 @@
1
+ // src/cli/renderers/report-fonts.js
2
+ // The report's typefaces, embedded so the report stays a single offline file.
3
+ // Reads the committed WOFF2 files listed in fonts/FONTS_MANIFEST.json and
4
+ // returns one @font-face block of base64 data URIs (≈227 KB for the six
5
+ // families). html-index.js passes it to renderIndexHtml as opts.fontFaceCss,
6
+ // the same way it passes the replay viewer source, so html-index-core.js
7
+ // stays pure (no fs) and can be bundled for the browser demo.
8
+ //
9
+ // Node only: keep this module out of anything src/cli/preview-entry.js
10
+ // imports (npm run demo:preview fails the build if it leaks in).
11
+
12
+ import { readFileSync } from 'fs';
13
+ import { join, dirname } from 'path';
14
+ import { fileURLToPath } from 'url';
15
+ import { fontFaceCss } from './font-face-css.js';
16
+
17
+ export const FONTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'fonts');
18
+
19
+ // The rule format lives in font-face-css.js (shared with the browser demo);
20
+ // this only supplies the bytes from disk.
21
+ export function buildFontFaceCss(dir = FONTS_DIR) {
22
+ const manifest = JSON.parse(readFileSync(join(dir, 'FONTS_MANIFEST.json'), 'utf8'));
23
+ return fontFaceCss(manifest.files, p => readFileSync(join(dir, p)).toString('base64'));
24
+ }
@@ -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.9.0',
38
+ version: '0.9.1',
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.9.0',
13
+ version: '0.9.1',
14
14
  data: { integrity: { type: 'object' } }
15
15
  };
16
16
 
@@ -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.9.0";
5
+ export const VERSION = "0.9.1";
6
6
 
7
7
  // Default detection thresholds shared across presets.
8
8
  // Researchers can override any value at init() time.