cyborg-hunter 0.8.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 (41) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/CITATION.cff +2 -2
  3. package/README.md +6 -4
  4. package/dist/cyborg-hunter-replay.js +2 -2
  5. package/dist/cyborg-hunter.esm.js +3 -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/analyzers/score-weights.js +145 -0
  10. package/src/cli/analyzers/triage.js +55 -39
  11. package/src/cli/config.js +15 -0
  12. package/src/cli/preview-entry.js +6 -0
  13. package/src/cli/renderers/font-face-css.js +16 -0
  14. package/src/cli/renderers/fonts/FONTS_MANIFEST.json +69 -0
  15. package/src/cli/renderers/fonts/majormonodisplay/OFL.txt +93 -0
  16. package/src/cli/renderers/fonts/majormonodisplay/majormonodisplay-400.woff2 +0 -0
  17. package/src/cli/renderers/fonts/recursive/OFL.txt +93 -0
  18. package/src/cli/renderers/fonts/recursive/recursive-300-1000.woff2 +0 -0
  19. package/src/cli/renderers/fonts/sofiasans/OFL.txt +93 -0
  20. package/src/cli/renderers/fonts/sofiasans/sofiasans-1-1000.woff2 +0 -0
  21. package/src/cli/renderers/fonts/sora/OFL.txt +93 -0
  22. package/src/cli/renderers/fonts/sora/sora-100-800.woff2 +0 -0
  23. package/src/cli/renderers/fonts/spacegrotesk/OFL.txt +93 -0
  24. package/src/cli/renderers/fonts/spacegrotesk/spacegrotesk-300-700.woff2 +0 -0
  25. package/src/cli/renderers/fonts/tomorrow/OFL.txt +93 -0
  26. package/src/cli/renderers/fonts/tomorrow/tomorrow-400.woff2 +0 -0
  27. package/src/cli/renderers/html-index-core.js +120 -106
  28. package/src/cli/renderers/html-index.js +7 -2
  29. package/src/cli/renderers/replay-styles.js +61 -0
  30. package/src/cli/renderers/replay-viewer.client.js +8 -6
  31. package/src/cli/renderers/report-fonts.js +24 -0
  32. package/src/cli/renderers/score-weights.js +16 -0
  33. package/src/cli/renderers/summary-csv.js +2 -0
  34. package/src/cli/renderers/trajectories-core.js +2 -1
  35. package/src/cli/renderers/triage-md.js +9 -2
  36. package/src/cli/report.js +10 -3
  37. package/src/jspsych/extension-cyborg-hunter-replay.js +1 -1
  38. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  39. package/src/shared/constants.js +1 -1
  40. package/src/shared/schema.js +1 -0
  41. package/src/shared/validation.js +1 -1
@@ -21,6 +21,8 @@
21
21
 
22
22
  import { VERSION, sanitizeId } from '../../shared/constants.js';
23
23
  import { decomposeScore } from '../analyzers/triage.js';
24
+ import { resolveScoreWeights, customWeightsText, formatScore } from '../analyzers/score-weights.js';
25
+ import { REPLAY_STYLES_CSS } from './replay-styles.js';
24
26
  import { getByPath } from '../../shared/paths.js';
25
27
  import { inferTier } from '../../replay/viewer-model.js';
26
28
  import { inlineSafeJson, inlineSafeSrc } from '../../shared/inline-safe.js';
@@ -43,6 +45,10 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
43
45
  // was hand-rolled in nine places and missing from exactly this one, which is
44
46
  // why the rule now lives in one module (T5 Task 10 + its fix round 3).
45
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' : '';
46
52
  const visualsUnavailableNote = opts.visualsUnavailableNote
47
53
  ?? 'Visual renderers not available (install the canvas package).';
48
54
  const imageSources = opts.imageSources ?? null; // pid → {typingProfile, sessionTimeline, trajectories} data URIs (null entry = omit that img)
@@ -104,28 +110,41 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
104
110
  <meta name="viewport" content="width=device-width, initial-scale=1">
105
111
  <title>Cyborg Hunter Report</title>
106
112
  <style>
107
- :root {
108
- --bg: #fafafa; --surface: #fff; --ink: #1a1814;
109
- --dim: #706a5c; --line: #e2ddd1;
113
+ ${fontFaceCss} :root {
114
+ --bg: #fafafa; --surface: #fff; --ink: #0f0f0f;
115
+ --dim: #4f4a40; --line: #b9b2a2;
110
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;
111
126
  }
112
127
  * { box-sizing: border-box; margin: 0; padding: 0; }
113
128
  html, body { height: 100%; }
114
129
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
115
130
  color: var(--ink); background: var(--bg); display: grid; grid-template-rows: auto 1fr; height: 100vh; }
116
131
  .topbar { display: flex; align-items: center; gap: 16px; padding: 12px 20px;
117
- background: var(--surface); border-bottom: 1px solid var(--line); }
118
- .topbar h1 { font-size: 18px; font-weight: 600; }
119
- .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; }
120
135
  .topbar button { padding: 6px 12px; border: 1px solid var(--line); background: var(--surface);
121
- 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; }
122
138
  .topbar button:hover { background: var(--bg); }
123
139
  .layout { display: grid; grid-template-columns: 360px 1fr; height: 100%; overflow: hidden; }
124
140
  .rail { background: var(--surface); border-right: 1px solid var(--line); overflow-y: auto; }
125
141
  .detail { overflow-y: auto; padding: 20px; }
126
- .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). */
127
146
  .mono {
128
- font: 13px/1.3 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
147
+ font: 13px/1.3 var(--ff-sora);
129
148
  font-variant-numeric: tabular-nums;
130
149
  }
131
150
  [hidden] { display: none !important; }
@@ -141,34 +160,43 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
141
160
  padding: 10px 12px; border-top: 1px solid var(--line);
142
161
  background: var(--surface); font-size: 12px;
143
162
  }
144
- .totals-title { color: var(--dim); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; }
145
- .totals-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; }
146
- .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; }
147
166
 
148
167
  /* Search */
149
168
  .search-wrap input {
150
169
  width: 100%; padding: 6px 8px; border: 1px solid var(--line);
151
- border-radius: 4px; font-size: 13px; background: var(--bg);
170
+ border-radius: 4px; background: var(--bg);
152
171
  color: var(--ink);
172
+ font-family: var(--ff-recursive); font-size: 19.5px; font-weight: 400; letter-spacing: 0;
153
173
  }
154
174
  .search-wrap input:focus { outline: 1px solid var(--ink); border-color: var(--ink); }
155
175
 
156
- /* Filter chips */
157
- .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; }
158
180
  .filter-chip {
159
- padding: 3px 8px; border: 1px solid var(--line); background: var(--surface);
160
- 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;
161
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; }
162
187
  .filter-chip:hover { background: var(--bg); }
188
+ /* Raised so the next chip's -1px overlap can't paint over its ink edge. */
163
189
  .filter-chip.active {
164
190
  background: var(--ink); color: var(--surface); border-color: var(--ink);
191
+ position: relative; z-index: 1;
165
192
  }
166
193
 
167
194
  /* Sort selector */
168
- .sort-wrap { font-size: 12px; color: var(--dim); }
195
+ .sort-wrap { font-family: var(--ff-tomorrow); font-size: 12px; color: var(--dim); }
169
196
  .sort-wrap select {
170
197
  background: var(--surface); color: var(--ink); border: 1px solid var(--line);
171
- 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;
172
200
  }
173
201
 
174
202
  /* Cohort rows */
@@ -177,23 +205,27 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
177
205
  cursor: pointer;
178
206
  }
179
207
  .cohort-row:hover { background: var(--bg); }
208
+ /* Selected row: a 2px inset ink outline (no layout shift, row tone kept). */
180
209
  .cohort-row.selected {
181
- border-left: 4px solid var(--ink);
182
- padding-left: 8px; /* compensate for the 4px left border */
183
- background: var(--bg);
210
+ border-left: 0;
211
+ padding-left: 12px;
212
+ background: transparent;
213
+ box-shadow: inset 0 0 0 2px var(--ink);
184
214
  }
185
215
  .cohort-row-top { display: flex; align-items: center; gap: 8px; }
186
216
  .cohort-row-top .pid {
217
+ font-family: var(--ff-sora);
187
218
  flex: 1; min-width: 0; color: var(--ink);
188
219
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
189
220
  }
190
- .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; }
191
222
  .cohort-row.selected .cohort-row-top .score { color: var(--ink); font-weight: 600; }
192
223
  .cohort-row-bot {
193
224
  display: flex; align-items: flex-start; gap: 8px;
194
225
  margin-top: 4px;
195
226
  }
196
227
  .reason-excerpt {
228
+ font-family: var(--ff-recursive);
197
229
  flex: 1; color: var(--dim); font-size: 12px;
198
230
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
199
231
  overflow: hidden; text-overflow: ellipsis;
@@ -208,9 +240,10 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
208
240
  .tier-dot[data-tier="soft"] { background: var(--soft); }
209
241
  .tier-dot[data-tier="clean"] { background: var(--clean); }
210
242
 
211
- /* Tier badge — small pill, filled for hard/soft, outline for clean */
243
+ /* Tier badge — small square tag, filled for hard/soft, outline for clean */
212
244
  .tier-badge {
213
- 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;
214
247
  font-weight: 600; letter-spacing: 0.04em; flex-shrink: 0;
215
248
  }
216
249
  .tier-badge[data-tier="hard"] { background: var(--hard); color: white; }
@@ -244,17 +277,21 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
244
277
 
245
278
  /* Header strip */
246
279
  .detail-header {
247
- border-bottom: 1px solid var(--line);
280
+ border-bottom: 1px solid var(--ink);
248
281
  padding-bottom: 12px; margin-bottom: 16px;
249
282
  }
250
283
  .detail-header-top { display: flex; align-items: center; gap: 12px; }
251
- .detail-header-top .pid-full { flex: 1; font-size: 13px; color: var(--ink); }
252
- .detail-header-top .score-big { font-size: 36px; font-weight: 600; color: var(--ink); }
253
- .detail-header-sub { color: var(--dim); font-size: 12px; margin-top: 4px; }
254
-
255
- /* 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) */
256
292
  .tier-pill {
257
- 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;
258
295
  font-weight: 600; letter-spacing: 0.04em;
259
296
  }
260
297
  .tier-pill[data-tier="hard"] { background: var(--hard); color: white; }
@@ -275,7 +312,7 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
275
312
  }
276
313
  .signal-tile {
277
314
  border: 1px solid var(--line);
278
- border-radius: 4px;
315
+ border-radius: 0;
279
316
  padding: 8px 10px;
280
317
  display: flex;
281
318
  flex-direction: column;
@@ -289,8 +326,8 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
289
326
  .signal-tile.tone-warn { color: var(--soft); border-color: var(--soft); background: rgba(245, 124, 0, 0.04); }
290
327
  .signal-tile.tone-muted { color: var(--ink); border-color: var(--ink); background: rgba(26, 24, 20, 0.03); }
291
328
  .signal-tile.tone-zero { color: #b8b1a0; border-color: #ebe6d8; background: transparent; }
292
- .signal-value { font-size: 18px; font-weight: 700; line-height: 1; }
293
- .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; }
294
331
 
295
332
  /* Score breakdown — horizontal flex of weighted contributions to t.score,
296
333
  ending in "Total: N". Only non-zero terms render; the bar widths are
@@ -303,21 +340,22 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
303
340
  display: inline-flex; gap: 6px; align-items: center;
304
341
  font-size: 12px;
305
342
  }
306
- .score-term .label { color: var(--dim); }
307
- .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); }
308
345
  .score-term .bar {
309
- height: 6px; background: var(--ink); opacity: 0.4; border-radius: 3px;
346
+ height: 6px; background: var(--ink); opacity: 0.4; border-radius: 2px;
310
347
  }
311
- .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); }
312
349
 
313
- /* Reason pull-quote — warm-tinted block-quote with a left rule. The bg
314
- color is picked by hand from --bg's family rather than computed, so
315
- 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). */
316
352
  .reason {
317
- border-left: 3px solid var(--ink);
318
- background: #f5f1e8;
353
+ background: var(--surface);
354
+ border: 1px solid var(--line);
355
+ border-radius: 4px;
319
356
  padding: 10px 14px;
320
357
  margin: 0 0 16px;
358
+ font-family: var(--ff-tomorrow);
321
359
  font-size: 14px;
322
360
  color: var(--ink);
323
361
  }
@@ -325,6 +363,7 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
325
363
  /* Session-level signals — small cards in a responsive grid. The grid
326
364
  collapses from 3 cells to 1 in narrow detail panes via auto-fit. */
327
365
  .section-heading {
366
+ font-family: var(--ff-sofia);
328
367
  font-size: 13px; color: var(--dim);
329
368
  text-transform: uppercase; letter-spacing: 0.04em;
330
369
  margin: 18px 0 8px;
@@ -336,14 +375,16 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
336
375
  }
337
376
  .sig-cell {
338
377
  background: var(--surface); border: 1px solid var(--line);
339
- 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);
340
380
  }
341
381
  .sig-cell-title {
382
+ font-family: var(--ff-sofia);
342
383
  font-size: 11px; color: var(--dim);
343
384
  text-transform: uppercase; letter-spacing: 0.04em;
344
385
  }
345
386
  .sig-cell ul { list-style: none; margin-top: 4px; font-size: 12px; padding: 0; }
346
- .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; }
347
388
  .muted { color: var(--dim); }
348
389
 
349
390
  /* Paste evidence — list of expandable entries */
@@ -358,10 +399,11 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
358
399
  }
359
400
  .paste-toggle:disabled { cursor: default; }
360
401
  .paste-toggle:hover:not(:disabled) { color: var(--ink); }
361
- .paste-trial { color: var(--dim); flex-shrink: 0; }
402
+ .paste-trial { font-family: var(--ff-majormono); color: var(--dim); flex-shrink: 0; }
362
403
  .paste-preview, .paste-full {
363
- background: #f5f1e8; /* warm tinted bg, same as the reason pull-quote */
364
- 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);
365
407
  font-size: 12px;
366
408
  word-break: break-word; white-space: pre-wrap;
367
409
  flex: 1; min-width: 0; /* allow flex item to shrink and wrap correctly */
@@ -369,7 +411,7 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
369
411
  /* When expanded by Task 11's JS: hide preview, show full text */
370
412
  .paste-entry.expanded .paste-preview { display: none; }
371
413
  .paste-entry:not(.expanded) .paste-full { display: none; }
372
- .paste-overflow { font-size: 12px; margin-top: 4px; }
414
+ .paste-overflow { font-family: var(--ff-tomorrow); font-size: 12px; margin-top: 4px; }
373
415
 
374
416
  /* Image blocks — wrapper containers so a missing PNG hides its heading too.
375
417
  Each .image-block contains a <h4 class="section-heading"> + a zoomable
@@ -382,7 +424,8 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
382
424
  .image-block .section-heading { margin: 0 0 6px; }
383
425
  .detail img {
384
426
  max-width: 100%; display: block; margin: 6px 0 0;
385
- 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);
386
429
  }
387
430
  .zoomable {
388
431
  display: block; cursor: zoom-in;
@@ -422,7 +465,7 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
422
465
  max-width: 560px; max-height: calc(100vh - 16vh);
423
466
  margin: 8vh auto 0;
424
467
  padding: 20px 24px;
425
- border-radius: 8px;
468
+ border-radius: 0;
426
469
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
427
470
  overflow-y: auto;
428
471
  }
@@ -435,6 +478,7 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
435
478
  }
436
479
  .modal-close:hover { color: var(--ink); }
437
480
  .modal-card h3 {
481
+ font-family: var(--ff-space);
438
482
  font-size: 16px; margin-bottom: 12px;
439
483
  }
440
484
  .legend-table {
@@ -446,7 +490,9 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
446
490
  border-bottom: 1px solid var(--line);
447
491
  vertical-align: top;
448
492
  }
493
+ .legend-table td { font-family: var(--ff-recursive); }
449
494
  .legend-table th {
495
+ font-family: var(--ff-sofia);
450
496
  color: var(--dim); font-weight: 500; font-size: 12px;
451
497
  text-transform: uppercase; letter-spacing: 0.04em;
452
498
  }
@@ -459,7 +505,7 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
459
505
  <body data-filter="all">
460
506
  <header class="topbar">
461
507
  <h1>Cyborg Hunter Report</h1>
462
- <span class="meta">${triage.length} participants &middot; v${VERSION}</span>
508
+ <span class="meta">${triage.length} participants &middot; v${VERSION}${scoreWeightsNote(config)}</span>
463
509
  <button class="legend-btn" type="button" aria-haspopup="dialog" aria-controls="legend-modal">Legend &#9432;</button>
464
510
  </header>
465
511
  <div class="layout">
@@ -728,50 +774,7 @@ export async function renderIndexHtml(summaries, triage, participants, config, v
728
774
  })();
729
775
  </script>
730
776
  <style>
731
- /* Replay viewer (see replay-viewer.client.js) — matches report house style */
732
- .replay-header { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 8px 0; }
733
- .replay-badge { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px;
734
- background: var(--ink); color: var(--surface); }
735
- .replay-badge[data-tier="trace"] { background: var(--surface); color: var(--ink);
736
- border: 1px solid var(--line); }
737
- .replay-stage { position: relative; overflow: hidden; background: var(--surface);
738
- border: 1px solid var(--line); border-radius: 4px; }
739
- .replay-frame { position: absolute; top: 0; left: 0; border: 0; }
740
- .replay-overlay { position: absolute; top: 0; left: 0; pointer-events: none; }
741
- .replay-neutral { background: #e8e6e0; }
742
- .replay-neutral-label { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
743
- color: var(--dim); font-size: 13px; }
744
- .replay-lane { display: block; margin-top: 6px; border-radius: 2px; }
745
- .replay-scrub { display: block; margin: 2px 0 4px; }
746
- .replay-ticker { font: 12px/1.3 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
747
- color: var(--dim); font-variant-numeric: tabular-nums;
748
- height: 1.4em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
749
- .replay-note { font-size: 12px; color: var(--dim); }
750
- .replay-warn { color: var(--hard); }
751
- /* Controls share the report's flat, line-bordered button style */
752
- .replay-play, .replay-load-btn, .replay-css-btn, .replay-segment-select, .replay-speed {
753
- padding: 4px 10px; border: 1px solid var(--line); background: var(--surface);
754
- color: var(--ink); border-radius: 4px; cursor: pointer; font-size: 13px; }
755
- .replay-play:hover, .replay-load-btn:hover, .replay-css-btn:hover { background: var(--bg); }
756
- .replay-play:disabled, .replay-load-btn:disabled { opacity: 0.6; cursor: default; }
757
- .replay-fetch-css-label { margin-left: 10px; font-size: 12px; color: #555; }
758
- .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; }
759
- .replay-css-btn { font-size: 12px; padding: 2px 8px; }
760
- .replay-clock { font: 12px/1.3 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
761
- font-variant-numeric: tabular-nums; }
762
- .replay-keycast { position: absolute; left: 0; right: 0; bottom: 0; display: flex;
763
- gap: 4px; padding: 5px 6px; pointer-events: none; flex-wrap: wrap-reverse; }
764
- .replay-key-chip { font: 11px/1.2 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
765
- background: rgba(0,0,0,0.72); color: #fff; padding: 2px 7px;
766
- border-radius: 3px; white-space: nowrap; }
767
- .replay-key-chip--redacted { background: rgba(0,0,0,0.5); font-style: italic; }
768
- /* Media is reported as state, never played (design §7): the badges say what
769
- the recorded element was doing, in the stage's top-left. */
770
- .replay-media { position: absolute; left: 0; top: 0; display: flex; gap: 4px;
771
- padding: 5px 6px; pointer-events: none; flex-wrap: wrap; }
772
- .replay-media-badge { font: 11px/1.2 ui-monospace, SFMono-Regular, 'IBM Plex Mono', monospace;
773
- background: rgba(0,137,123,0.85); color: #fff; padding: 2px 7px;
774
- border-radius: 3px; white-space: nowrap; }
777
+ ${REPLAY_STYLES_CSS}
775
778
  </style>
776
779
  ${preloadedReplayScript}<script>
777
780
  ${replayClientSrc}
@@ -846,7 +849,8 @@ function tierOf(t) {
846
849
  // drop, long tab-aways, AI extensions detected).
847
850
  // warn — soft-score-contributing signals that compound with others.
848
851
  // muted — diagnostic signals that are noise alone but corroborate.
849
- // Tone classes track scoring weights; if computeTriageScore changes, audit this.
852
+ // Tone classes track the DEFAULT score weights (score-weights.js); custom
853
+ // config.scoreWeights do not retone the tiles. If the defaults change, audit this.
850
854
  // `aiExtensionsCount` and `edgeExitCount` are derived (not on summary directly);
851
855
  // renderSignalGrid handles that mapping.
852
856
  const SIGNALS = [
@@ -960,7 +964,7 @@ function renderCohortRow(t) {
960
964
  <div class="cohort-row-top">
961
965
  <span class="tier-dot" data-tier="${tier}"></span>
962
966
  <span class="mono pid" title="${esc(pid)}">${esc(pid)}</span>
963
- <span class="mono score">${score}</span>
967
+ <span class="mono score">${formatScore(score)}</span>
964
968
  </div>
965
969
  <div class="cohort-row-bot">
966
970
  <span class="reason-excerpt">${esc(reason)}</span>
@@ -1034,7 +1038,7 @@ function renderDetail(t, participant, config, visualsRendered, visualsUnavailabl
1034
1038
  return `<section class="participant" id="p-${sanitized}"${defaultVisible ? '' : ' hidden'}>
1035
1039
  ${renderDetailHeader(t, tier, participant, config)}
1036
1040
  ${renderSignalGrid(s, t)}
1037
- ${renderScoreBreakdown(t)}
1041
+ ${renderScoreBreakdown(t, config)}
1038
1042
  ${renderReasonQuote(t)}
1039
1043
  ${renderSessionBlock(s, participant)}
1040
1044
  ${renderPasteEvidence(participant)}
@@ -1238,7 +1242,7 @@ function renderDetailHeader(t, tier, participant, config) {
1238
1242
  <div class="detail-header-top">
1239
1243
  <span class="mono pid-full">${esc(t.participantId)}</span>
1240
1244
  <span class="tier-pill" data-tier="${tier}">${tierLabel}</span>
1241
- <span class="mono score-big">${t.score}</span>
1245
+ <span class="mono score-big">${formatScore(t.score)}</span>
1242
1246
  </div>
1243
1247
  <div class="detail-header-sub">
1244
1248
  ${s.trialCount ?? 0} trials · ${cps} cps · ${offTask} off-task
@@ -1247,9 +1251,12 @@ function renderDetailHeader(t, tier, participant, config) {
1247
1251
  }
1248
1252
 
1249
1253
  // Horizontal score breakdown — only non-zero contributions, ending in Total:N.
1250
- // Sourced from decomposeScore so the displayed terms always sum to t.score.
1251
- function renderScoreBreakdown(t) {
1252
- const terms = decomposeScore(t.summary || {}, t.edgeExitCount, t.hardTriggered)
1254
+ // Draws the terms rankTriage actually summed (t.terms). Rows built elsewhere
1255
+ // without terms (hand-built callers) are decomposed here with the CONFIGURED
1256
+ // weights, so the bars never disagree with a custom-weights header.
1257
+ function renderScoreBreakdown(t, config) {
1258
+ const terms = (t.terms ?? decomposeScore(t.summary || {}, t.edgeExitCount, t.hardTriggered,
1259
+ resolveScoreWeights(config?.scoreWeights).weights))
1253
1260
  .filter(([, n]) => n > 0);
1254
1261
 
1255
1262
  const total = t.score;
@@ -1259,17 +1266,24 @@ function renderScoreBreakdown(t) {
1259
1266
  const termHtml = terms.map(([label, n]) =>
1260
1267
  `<span class="score-term">
1261
1268
  <span class="label">${label}</span>
1262
- <span class="mono contrib">+${n}</span>
1269
+ <span class="mono contrib">+${formatScore(n)}</span>
1263
1270
  <span class="bar" style="width:${barFor(n)}px"></span>
1264
1271
  </span>`
1265
1272
  ).join('');
1266
1273
 
1267
1274
  return `<div class="score-breakdown">
1268
1275
  ${termHtml}
1269
- <span class="score-total mono">Total: ${total}</span>
1276
+ <span class="score-total mono">Total: ${formatScore(total)}</span>
1270
1277
  </div>`;
1271
1278
  }
1272
1279
 
1280
+ // Top-bar note, present only when config.scoreWeights changes the score from
1281
+ // the defaults: " · custom score weights: copy 5 max 3, synthetic 1".
1282
+ function scoreWeightsNote(config) {
1283
+ const { weights, isDefault } = resolveScoreWeights(config?.scoreWeights);
1284
+ return isDefault ? '' : ` &middot; custom score weights: ${esc(customWeightsText(weights))}`;
1285
+ }
1286
+
1273
1287
  // Format milliseconds as "Xs" / "Xm Ys".
1274
1288
  function formatDuration(ms) {
1275
1289
  if (!ms || ms < 1000) return '0s';
@@ -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
+ }
@@ -0,0 +1,16 @@
1
+ // src/cli/renderers/score-weights.js
2
+ // Writes score-weights.json — the weights this report's triage score was built
3
+ // with (config.scoreWeights merged onto the defaults). Always written, so two
4
+ // reports can be checked for comparability without their config files.
5
+ // Warnings stay on the console (config.js); this file holds weights only.
6
+
7
+ import { writeFileSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { resolveScoreWeights } from '../analyzers/score-weights.js';
10
+
11
+ export function renderScoreWeights(config) {
12
+ const { weights, isDefault } = resolveScoreWeights(config?.scoreWeights);
13
+ writeFileSync(join(config.outputDir, 'score-weights.json'),
14
+ JSON.stringify({ isDefault, weights }, null, 2) + '\n');
15
+ console.log(` score-weights.json — ${isDefault ? 'default' : 'custom'} weights`);
16
+ }
@@ -9,6 +9,8 @@ import { join } from 'path';
9
9
  const COLUMNS = [
10
10
  ['participantId', (s, t) => s.participantId],
11
11
  ['trialCount', (s, t) => s.trialCount],
12
+ // The exact score rankTriage sorted on (not the one-decimal display form),
13
+ // so the analysis file never disagrees with the ranking.
12
14
  ['triageScore', (s, t) => t.score],
13
15
  ['hardTriggered', (s, t) => t.hardTriggered ? 'YES' : 'no'],
14
16
  ['triageReason', (s, t) => t.reason],