@tracelane/report 0.1.0-alpha.1 → 0.1.0-alpha.10

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.
package/dist/template.js CHANGED
@@ -1,51 +1,546 @@
1
- // HTML composition for the self-contained report (P1 PRD §F.1).
1
+ // HTML composition for the self-contained report (P1 PRD §F.1 + Phase 6
2
+ // editorial-postmortem revamp).
2
3
  //
3
4
  // `renderReportHtml` assembles the single-file document from already-prepared
4
5
  // pieces (the caller — build-report.ts — does the extraction + encoding). This
5
- // module owns the static shell: the report CSS, the metadata header markup, the
6
- // panel containers, and the in-page bootstrap script. The large vendored assets
7
- // (player UMD/CSS, fflate UMD) and the data payloads are passed in.
8
- import { loadFflateGunzipSource, loadPlayerCss, loadPlayerUmd } from './assets.js';
6
+ // module owns the static shell: the report CSS, the hero header markup, the
7
+ // player + tabbed-panel containers, the in-page bootstrap script, and the
8
+ // FAB. The large vendored assets (player UMD/CSS, fflate UMD, both variable
9
+ // fonts) and the data payloads are passed in.
10
+ import { loadFflateGunzipSource, loadFrauncesItalic, loadFrauncesNormal, loadJetBrainsMonoNormal, loadPlayerCss, loadPlayerUmd, } from './assets.js';
9
11
  import { escapeHtml, serializeForScript } from './html.js';
10
- import { renderMetaHeader } from './metadata.js';
11
- /** Report shell CSS (~ a few KB). Kept terse; no external fonts or assets. */
12
- const SHELL_CSS = `
13
- :root { color-scheme: light dark; --fg:#1a1a1a; --bg:#fff; --muted:#666; --border:#e2e2e2; --accent:#2563eb; --err:#dc2626; --warn:#d97706; --panel:#fafafa; }
14
- @media (prefers-color-scheme: dark){ :root { --fg:#e6e6e6; --bg:#161616; --muted:#9a9a9a; --border:#2c2c2c; --accent:#60a5fa; --err:#f87171; --warn:#fbbf24; --panel:#1e1e1e; } }
12
+ import { renderHero } from './metadata.js';
13
+ /**
14
+ * Compose the SHELL_CSS at build time so the base64-encoded woff2 strings can
15
+ * be interpolated into `@font-face` rules. Returns a single CSS string the
16
+ * report's `<style>` tag wraps. Kept as a function (not a const) so the woff2
17
+ * reads happen at build time, not import time — matters because the loaders
18
+ * touch the filesystem.
19
+ *
20
+ * Aesthetic: editorial postmortem. Dark slate background, off-white text,
21
+ * teal-and-amber accent palette. Fraunces serif for the hero headline + section
22
+ * heads (italic-emphasized clause carries the failure colour), JetBrains Mono
23
+ * variable for every data row + the eyebrow + the meta strip + the panels.
24
+ */
25
+ function buildShellCss() {
26
+ const frauncesNormal = loadFrauncesNormal();
27
+ const frauncesItalic = loadFrauncesItalic();
28
+ const jbMonoNormal = loadJetBrainsMonoNormal();
29
+ // Inline @font-face declarations — each `url(data:...)` is a base64 woff2 that
30
+ // resolves with no network request. font-display:block is intentional: the
31
+ // serif headline is the first thing the eye lands on and a fallback flash
32
+ // would betray the design (it's <50 ms anyway, the data URL is local).
33
+ const fontFaces = `
34
+ @font-face {
35
+ font-family: 'Fraunces';
36
+ font-style: normal;
37
+ font-display: block;
38
+ font-weight: 100 900;
39
+ src: url(data:font/woff2;base64,${frauncesNormal}) format('woff2');
40
+ }
41
+ @font-face {
42
+ font-family: 'Fraunces';
43
+ font-style: italic;
44
+ font-display: block;
45
+ font-weight: 100 900;
46
+ src: url(data:font/woff2;base64,${frauncesItalic}) format('woff2');
47
+ }
48
+ @font-face {
49
+ font-family: 'JetBrains Mono';
50
+ font-style: normal;
51
+ font-display: block;
52
+ font-weight: 100 800;
53
+ src: url(data:font/woff2;base64,${jbMonoNormal}) format('woff2');
54
+ }`;
55
+ // CSS variables + reset + every component in document order. ~6 KB minified.
56
+ const styles = `
57
+ :root {
58
+ color-scheme: dark;
59
+ --bg: #0f1115;
60
+ --surface: #171a20;
61
+ --surface-2: #1d2027;
62
+ --border: #2a2e36;
63
+ --border-strong: #383d47;
64
+ --text: #e7e5e1;
65
+ --muted: #8a92a0;
66
+ --muted-strong: #b8bfca;
67
+ --teal: #5eead4;
68
+ --teal-dim: rgba(94, 234, 212, 0.18);
69
+ --amber: #f5a364;
70
+ --amber-dim: rgba(245, 163, 100, 0.18);
71
+ --warn: #f0c674;
72
+ --serif: 'Fraunces', ui-serif, Georgia, serif;
73
+ --mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
74
+ }
15
75
  * { box-sizing: border-box; }
16
- body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; color:var(--fg); background:var(--bg); }
17
- header.meta { padding:12px 16px; border-bottom:1px solid var(--border); }
18
- header.meta h1 { margin:0 0 6px; font-size:16px; }
19
- header.meta .status { display:inline-block; padding:1px 8px; border-radius:10px; font-size:12px; font-weight:600; text-transform:uppercase; }
20
- header.meta .status.failed,header.meta .status.broken { background:var(--err); color:#fff; }
21
- header.meta .status.passed { background:#16a34a; color:#fff; }
22
- header.meta .status.skipped { background:var(--muted); color:#fff; }
23
- header.meta dl { display:grid; grid-template-columns:max-content 1fr; gap:2px 12px; margin:8px 0 0; font-size:13px; }
24
- header.meta dt { color:var(--muted); }
25
- header.meta dd { margin:0; word-break:break-word; }
26
- header.meta .error { margin-top:8px; padding:8px; border-left:3px solid var(--err); background:var(--panel); white-space:pre-wrap; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; }
27
- .banner { padding:6px 16px; background:var(--warn); color:#1a1a1a; font-size:13px; }
28
- .toolbar { padding:8px 16px; border-bottom:1px solid var(--border); }
29
- button.copy-md { font:inherit; padding:6px 12px; border:1px solid var(--accent); background:var(--accent); color:#fff; border-radius:6px; cursor:pointer; }
30
- button.copy-md:active { opacity:.8; }
31
- main { display:flex; gap:0; align-items:stretch; min-height:60vh; }
32
- #player { flex:1 1 auto; min-width:0; padding:12px; overflow:auto; }
33
- aside#panels { flex:0 0 360px; border-left:1px solid var(--border); display:flex; flex-direction:column; overflow:hidden; }
34
- aside#panels section { display:flex; flex-direction:column; min-height:0; flex:1 1 50%; }
35
- aside#panels h2 { margin:0; padding:8px 12px; font-size:13px; background:var(--panel); border-bottom:1px solid var(--border); position:sticky; top:0; }
36
- .rows { overflow:auto; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; }
37
- .row { padding:4px 12px; border-bottom:1px solid var(--border); white-space:pre-wrap; word-break:break-word; }
38
- .row.error { color:var(--err); } .row.warn { color:var(--warn); }
39
- .row .lvl { font-weight:600; margin-right:6px; }
40
- .row .st { font-weight:600; margin-right:6px; } .row .st4,.row .st5 { color:var(--err); }
41
- .empty { padding:12px; color:var(--muted); font-style:italic; }
76
+ html, body { margin: 0; padding: 0; }
77
+ body {
78
+ font-family: var(--mono);
79
+ font-size: 13px;
80
+ line-height: 1.55;
81
+ color: var(--text);
82
+ background: var(--bg);
83
+ -webkit-font-smoothing: antialiased;
84
+ -moz-osx-font-smoothing: grayscale;
85
+ }
86
+ a { color: var(--teal); text-decoration: none; }
87
+ a:hover { text-decoration: underline; }
88
+
89
+ /* ===== Hero: "What failed" ===== */
90
+ .hero {
91
+ padding: 48px 48px 32px;
92
+ border-bottom: 1px solid var(--border);
93
+ background:
94
+ radial-gradient(1200px 400px at -10% -20%, var(--amber-dim), transparent 50%),
95
+ radial-gradient(800px 300px at 110% 120%, var(--teal-dim), transparent 50%),
96
+ var(--bg);
97
+ }
98
+ .eyebrow {
99
+ font-family: var(--mono);
100
+ font-size: 11px;
101
+ font-weight: 600;
102
+ letter-spacing: 0.14em;
103
+ text-transform: uppercase;
104
+ color: var(--muted);
105
+ display: flex;
106
+ gap: 12px;
107
+ align-items: center;
108
+ margin-bottom: 18px;
109
+ flex-wrap: wrap;
110
+ }
111
+ .eyebrow .dot {
112
+ width: 8px; height: 8px; border-radius: 50%;
113
+ background: var(--amber);
114
+ box-shadow: 0 0 0 4px var(--amber-dim);
115
+ animation: pulse 2.6s ease-in-out infinite;
116
+ }
117
+ .eyebrow .status { color: var(--amber); font-weight: 700; }
118
+ .eyebrow .status.passed { color: var(--teal); }
119
+ .eyebrow .status.skipped { color: var(--muted); }
120
+ .eyebrow .status.broken { color: var(--amber); }
121
+ .eyebrow .sep { color: var(--border-strong); }
122
+
123
+ @keyframes pulse {
124
+ 0%, 100% { box-shadow: 0 0 0 4px var(--amber-dim); }
125
+ 50% { box-shadow: 0 0 0 8px rgba(245, 163, 100, 0.06); }
126
+ }
127
+
128
+ h1.what {
129
+ font-family: var(--serif);
130
+ font-weight: 600;
131
+ font-size: clamp(28px, 4vw, 44px);
132
+ line-height: 1.15;
133
+ letter-spacing: -0.02em;
134
+ margin: 0 0 12px;
135
+ color: var(--text);
136
+ max-width: 56ch;
137
+ }
138
+ h1.what em {
139
+ font-style: italic;
140
+ color: var(--amber);
141
+ font-weight: 500;
142
+ }
143
+
144
+ .error-message {
145
+ font-family: var(--mono);
146
+ font-size: 13px;
147
+ line-height: 1.65;
148
+ color: var(--muted-strong);
149
+ padding: 14px 16px;
150
+ border-left: 2px solid var(--amber);
151
+ background: rgba(245, 163, 100, 0.05);
152
+ margin: 18px 0 24px;
153
+ white-space: pre-wrap;
154
+ max-width: 90ch;
155
+ overflow-x: auto;
156
+ }
157
+
158
+ .meta-strip {
159
+ display: flex;
160
+ flex-wrap: wrap;
161
+ gap: 0;
162
+ border-top: 1px solid var(--border);
163
+ margin: 24px -48px -32px;
164
+ padding: 16px 48px;
165
+ background: rgba(0, 0, 0, 0.15);
166
+ }
167
+ .meta-strip .item {
168
+ padding: 4px 24px 4px 0;
169
+ margin-right: 24px;
170
+ border-right: 1px solid var(--border);
171
+ font-size: 12px;
172
+ }
173
+ .meta-strip .item:last-child { border-right: 0; margin-right: 0; }
174
+ .meta-strip .label {
175
+ color: var(--muted);
176
+ text-transform: uppercase;
177
+ letter-spacing: 0.08em;
178
+ font-size: 10px;
179
+ font-weight: 600;
180
+ display: block;
181
+ margin-bottom: 2px;
182
+ }
183
+ .meta-strip .value { color: var(--text); font-weight: 500; }
184
+ .meta-strip .value a { color: var(--teal); font-weight: 500; }
185
+ .meta-strip .value code {
186
+ font-family: var(--mono);
187
+ font-size: 12px;
188
+ color: var(--teal);
189
+ background: transparent;
190
+ padding: 0;
191
+ }
192
+
193
+ /* ===== Banner (size pruned) ===== */
194
+ .banner {
195
+ padding: 10px 48px;
196
+ background: rgba(240, 198, 116, 0.12);
197
+ border-bottom: 1px solid var(--border);
198
+ color: var(--warn);
199
+ font-size: 12px;
200
+ }
201
+
202
+ /* ===== Main: replay + investigation ===== */
203
+ main.investigation {
204
+ display: grid;
205
+ grid-template-columns: minmax(0, 1.5fr) minmax(420px, 1fr);
206
+ min-height: 70vh;
207
+ border-bottom: 1px solid var(--border);
208
+ }
209
+
210
+ /* Replay column */
211
+ .replay {
212
+ padding: 24px 32px;
213
+ border-right: 1px solid var(--border);
214
+ background: var(--bg);
215
+ display: flex;
216
+ flex-direction: column;
217
+ gap: 18px;
218
+ min-width: 0;
219
+ }
220
+ .replay-header {
221
+ display: flex;
222
+ align-items: baseline;
223
+ justify-content: space-between;
224
+ gap: 24px;
225
+ flex-wrap: wrap;
226
+ }
227
+ .replay-header h2 {
228
+ font-family: var(--serif);
229
+ font-weight: 500;
230
+ font-style: italic;
231
+ font-size: 20px;
232
+ margin: 0;
233
+ color: var(--text);
234
+ }
235
+ .replay-header .timestamp {
236
+ font-family: var(--mono);
237
+ font-size: 11px;
238
+ color: var(--muted);
239
+ letter-spacing: 0.04em;
240
+ }
241
+ #player {
242
+ background: var(--surface);
243
+ border: 1px solid var(--border);
244
+ border-radius: 4px;
245
+ position: relative;
246
+ overflow: hidden;
247
+ width: 100%;
248
+ display: flex;
249
+ align-items: center;
250
+ justify-content: center;
251
+ }
252
+ #player .empty {
253
+ position: absolute; inset: 0;
254
+ display: flex; align-items: center; justify-content: center;
255
+ color: var(--muted);
256
+ font-family: var(--mono);
257
+ font-size: 12px;
258
+ text-align: center;
259
+ padding: 16px;
260
+ }
261
+
262
+ /* Investigation panels column */
263
+ .panels {
264
+ background: var(--surface);
265
+ display: flex;
266
+ flex-direction: column;
267
+ overflow: hidden;
268
+ min-width: 0;
269
+ }
270
+ .tabs {
271
+ display: flex;
272
+ border-bottom: 1px solid var(--border);
273
+ background: var(--surface-2);
274
+ overflow-x: auto;
275
+ scrollbar-width: none;
276
+ }
277
+ .tabs::-webkit-scrollbar { display: none; }
278
+ .tab {
279
+ padding: 14px 20px;
280
+ font-family: var(--mono);
281
+ font-size: 12px;
282
+ color: var(--muted);
283
+ cursor: pointer;
284
+ border: 0;
285
+ background: transparent;
286
+ border-bottom: 2px solid transparent;
287
+ text-transform: lowercase;
288
+ letter-spacing: 0.04em;
289
+ transition: color 0.15s ease, border-color 0.15s ease;
290
+ font-weight: 500;
291
+ white-space: nowrap;
292
+ }
293
+ .tab:hover { color: var(--muted-strong); }
294
+ .tab.active {
295
+ color: var(--text);
296
+ border-bottom-color: var(--teal);
297
+ }
298
+ .tab .count {
299
+ margin-left: 6px;
300
+ padding: 1px 6px;
301
+ border-radius: 8px;
302
+ background: var(--border);
303
+ color: var(--muted);
304
+ font-size: 10px;
305
+ font-weight: 600;
306
+ }
307
+ .tab.active .count { background: var(--teal-dim); color: var(--teal); }
308
+
309
+ .panel-toolbar {
310
+ padding: 10px 16px;
311
+ border-bottom: 1px solid var(--border);
312
+ display: flex;
313
+ gap: 12px;
314
+ align-items: center;
315
+ flex-wrap: wrap;
316
+ }
317
+ .panel-filter {
318
+ flex: 1;
319
+ min-width: 160px;
320
+ background: var(--bg);
321
+ border: 1px solid var(--border);
322
+ border-radius: 4px;
323
+ padding: 6px 12px;
324
+ color: var(--text);
325
+ font-family: var(--mono);
326
+ font-size: 12px;
327
+ transition: border-color 0.15s ease;
328
+ }
329
+ .panel-filter:focus { outline: none; border-color: var(--teal); }
330
+ .panel-filter::placeholder { color: var(--muted); }
331
+ .filter-chip {
332
+ padding: 4px 10px;
333
+ border-radius: 12px;
334
+ border: 1px solid var(--border);
335
+ font-family: var(--mono);
336
+ font-size: 10px;
337
+ text-transform: uppercase;
338
+ letter-spacing: 0.08em;
339
+ color: var(--muted);
340
+ background: transparent;
341
+ cursor: pointer;
342
+ transition: all 0.15s ease;
343
+ }
344
+ .filter-chip:hover { border-color: var(--border-strong); color: var(--muted-strong); }
345
+ .filter-chip.active {
346
+ border-color: var(--amber);
347
+ color: var(--amber);
348
+ background: var(--amber-dim);
349
+ }
350
+
351
+ .panel-pane { display: none; flex-direction: column; min-height: 0; flex: 1; }
352
+ .panel-pane.active { display: flex; }
353
+
354
+ .panel-content {
355
+ flex: 1;
356
+ overflow-y: auto;
357
+ padding: 8px 0;
358
+ }
359
+ .panel-empty {
360
+ padding: 24px 16px;
361
+ color: var(--muted);
362
+ font-style: italic;
363
+ text-align: center;
364
+ font-size: 12px;
365
+ }
366
+
367
+ /* Time-sync: rows whose data-time is past the current playback time. */
368
+ .panel-content .row.is-future { display: none; }
369
+ .panel-content .row { cursor: pointer; }
370
+
371
+ /* Pending placeholder shown when rows exist but the playhead hasn't reached
372
+ any of them yet. Sibling of .panel-content inside .panel-pane. */
373
+ .panel-pending {
374
+ padding: 24px;
375
+ text-align: center;
376
+ color: var(--muted);
377
+ font-family: var(--mono);
378
+ font-size: 12px;
379
+ letter-spacing: 0.02em;
380
+ }
381
+ .panel-pending.is-hidden { display: none; }
382
+
383
+ /* Tab badge: <current> in default color, "/ <total>" in --muted. */
384
+ .tab .count-total {
385
+ color: var(--muted);
386
+ margin-left: 4px;
387
+ font-weight: normal;
388
+ }
389
+ .row {
390
+ padding: 8px 16px;
391
+ border-bottom: 1px solid var(--border);
392
+ display: grid;
393
+ grid-template-columns: max-content max-content 1fr;
394
+ gap: 14px;
395
+ font-family: var(--mono);
396
+ font-size: 11.5px;
397
+ line-height: 1.55;
398
+ transition: background 0.1s ease;
399
+ }
400
+ .row.error { background: rgba(245, 163, 100, 0.04); }
401
+ .row.hidden { display: none; }
402
+ .row .ts { color: var(--muted); font-size: 10.5px; padding-top: 1px; white-space: nowrap; }
403
+ .row .lvl {
404
+ font-weight: 700;
405
+ font-size: 10px;
406
+ text-transform: uppercase;
407
+ letter-spacing: 0.08em;
408
+ padding-top: 2px;
409
+ white-space: nowrap;
410
+ }
411
+ .row.error .lvl { color: var(--amber); }
412
+ .row.warn .lvl { color: var(--warn); }
413
+ .row.log .lvl,
414
+ .row.info .lvl,
415
+ .row.debug .lvl { color: var(--muted); }
416
+ .row .msg { color: var(--text); word-break: break-word; white-space: pre-wrap; }
417
+ .row .msg .method { color: var(--muted-strong); font-weight: 600; margin-right: 6px; }
418
+ .row.row-net .lvl.st-4,
419
+ .row.row-net .lvl.st-5 { color: var(--amber); }
420
+ .row.row-net .lvl.st-0 { color: var(--amber); } /* net error */
421
+
422
+ /* "Coming soon" pane content (Actions / Timeline tabs reserved for follow-ups) */
423
+ .coming-soon {
424
+ padding: 32px 16px;
425
+ text-align: center;
426
+ color: var(--muted);
427
+ font-family: var(--mono);
428
+ font-size: 12px;
429
+ display: flex;
430
+ flex-direction: column;
431
+ gap: 6px;
432
+ }
433
+ .coming-soon strong { color: var(--text); font-weight: 600; }
434
+
435
+ /* Floating action button: Copy as Markdown */
436
+ .fab {
437
+ position: fixed;
438
+ right: 28px;
439
+ bottom: 28px;
440
+ z-index: 100;
441
+ display: flex;
442
+ align-items: center;
443
+ gap: 10px;
444
+ padding: 14px 22px;
445
+ background: var(--surface);
446
+ border: 1px solid var(--border-strong);
447
+ border-radius: 999px;
448
+ color: var(--text);
449
+ font-family: var(--mono);
450
+ font-size: 12px;
451
+ font-weight: 500;
452
+ cursor: pointer;
453
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(94, 234, 212, 0.06);
454
+ transition: all 0.2s ease;
455
+ }
456
+ .fab:hover {
457
+ border-color: var(--teal);
458
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(94, 234, 212, 0.3);
459
+ transform: translateY(-1px);
460
+ }
461
+ .fab.copied {
462
+ border-color: var(--teal);
463
+ color: var(--teal);
464
+ box-shadow: 0 0 0 4px var(--teal-dim), 0 8px 32px rgba(0, 0, 0, 0.4);
465
+ }
466
+ .fab .icon {
467
+ width: 14px; height: 14px;
468
+ border: 1.5px solid var(--teal);
469
+ border-radius: 3px;
470
+ position: relative;
471
+ flex-shrink: 0;
472
+ }
473
+ .fab .icon::after {
474
+ content: '';
475
+ position: absolute;
476
+ top: -3px; left: 2px;
477
+ width: 10px; height: 11px;
478
+ border: 1.5px solid var(--teal);
479
+ border-radius: 2px;
480
+ background: var(--surface);
481
+ transition: opacity 0.2s ease;
482
+ }
483
+ .fab.copied .icon { border-color: var(--teal); }
484
+ .fab.copied .icon::after { opacity: 0; }
485
+ .fab.copied .icon::before {
486
+ content: '';
487
+ position: absolute;
488
+ top: 1px; left: 4px;
489
+ width: 4px; height: 8px;
490
+ border-right: 1.5px solid var(--teal);
491
+ border-bottom: 1.5px solid var(--teal);
492
+ transform: rotate(45deg);
493
+ }
494
+
495
+ /* Footer */
496
+ footer.attrib {
497
+ padding: 32px 48px;
498
+ border-top: 1px solid var(--border);
499
+ text-align: center;
500
+ font-family: var(--mono);
501
+ font-size: 11px;
502
+ color: var(--muted);
503
+ }
504
+ footer.attrib a { color: var(--muted-strong); }
505
+ footer.attrib em {
506
+ font-family: var(--serif);
507
+ font-style: italic;
508
+ font-size: 12px;
509
+ color: var(--muted-strong);
510
+ }
511
+
512
+ /* Mobile: stack the replay above panels; panels become full-width. */
513
+ @media (max-width: 900px) {
514
+ .hero { padding: 32px 24px 24px; }
515
+ .meta-strip { margin: 16px -24px -24px; padding: 12px 24px; gap: 4px 0; }
516
+ .meta-strip .item { border-right: 0; padding: 4px 0; margin-right: 24px; }
517
+ .banner { padding: 10px 24px; }
518
+ main.investigation { grid-template-columns: 1fr; }
519
+ .replay { border-right: 0; border-bottom: 1px solid var(--border); padding: 20px 24px; }
520
+ .panels { min-height: 50vh; }
521
+ .fab { right: 16px; bottom: 16px; padding: 12px 18px; font-size: 11px; }
522
+ h1.what { font-size: 28px; }
523
+ footer.attrib { padding: 24px 24px; }
524
+ }
525
+
526
+ /* rrweb-player wrapper: ensure it fills #player without overflow weirdness.
527
+ The controller has a white background; default text inherits our dark-theme
528
+ --text (near-white), making 2x/4x/8x speed buttons and the "skip inactive"
529
+ label invisible. Restore a dark text color scoped to the player controls. */
530
+ #player .rr-player { background: var(--surface) !important; }
531
+ #player .rr-controller__btns button { color: #11103e; }
532
+ #player .rr-controller__btns button.active { color: #fff; }
533
+ #player .rr-controller .switch .label { color: #11103e; }
42
534
  `;
535
+ return fontFaces + styles;
536
+ }
43
537
  /**
44
538
  * The in-page bootstrap (runs at view time, plain ES5-ish JS so it executes in
45
539
  * any browser without a build step). Reads the embedded payloads, decompresses
46
540
  * the events with the inlined fflate, mounts rrweb-player, renders the panels,
47
- * and wires the copy-as-markdown button. Authored as a single string so it ships
48
- * verbatim in a `<script>` it must not reference any TS/Node symbol.
541
+ * wires up tab switching + filter input + filter chips, and animates the
542
+ * Copy-as-Markdown FAB. Authored as a single string so it ships verbatim in a
543
+ * `<script>` — it must not reference any TS/Node symbol.
49
544
  */
50
545
  const BOOTSTRAP = `
51
546
  (function () {
@@ -53,21 +548,49 @@ const BOOTSTRAP = `
53
548
  var bin = atob(b64);
54
549
  var bytes = new Uint8Array(bin.length);
55
550
  for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
551
+ if (bytes.length === 0) return [];
56
552
  var json = fflate.strFromU8(fflate.gunzipSync(bytes));
57
553
  return JSON.parse(json);
58
554
  }
59
555
 
60
556
  var events = decodeEvents(EVENTS_GZ_B64);
61
557
 
62
- // rrweb-player needs at least two events to compute a timeline; guard so a
63
- // truncated/empty capture degrades to a message instead of throwing.
558
+ // ---- rrweb-player mount ------------------------------------------------
64
559
  var playerEl = document.getElementById('player');
560
+ var rrPlayer = null;
65
561
  if (events.length >= 2 && typeof rrwebPlayer !== 'undefined') {
66
- new rrwebPlayer({ target: playerEl, props: { events: events, showController: true, autoPlay: false } });
562
+ // Size the player to fill its container, preserving the recording's
563
+ // aspect ratio. rrweb-player's defaults (1024x576) overflow most layouts.
564
+ var recAspect = (META.viewport && META.viewport.width && META.viewport.height)
565
+ ? META.viewport.width / META.viewport.height
566
+ : 16 / 10;
567
+ var containerW = playerEl.clientWidth || 1024;
568
+ var maxIframeH = Math.max(window.innerHeight - 360, 280);
569
+ var iframeH = Math.min(Math.round(containerW / recAspect), maxIframeH);
570
+ var iframeW = Math.min(containerW, Math.round(iframeH * recAspect));
571
+ rrPlayer = new rrwebPlayer({
572
+ target: playerEl,
573
+ props: {
574
+ events: events,
575
+ width: iframeW,
576
+ height: iframeH,
577
+ showController: true,
578
+ autoPlay: false,
579
+ },
580
+ });
581
+ // Expose for headless probes (visible to dev-tools; no runtime impact on
582
+ // end users — the report HTML is read-only static content).
583
+ try { window.__player = rrPlayer; } catch (_) {}
67
584
  } else {
68
- playerEl.innerHTML = '<p class="empty">Not enough recorded events to replay.</p>';
585
+ var msg = document.createElement('div');
586
+ msg.className = 'empty';
587
+ msg.textContent = events.length === 0
588
+ ? 'No recorded events — the test crashed before the recorder produced a snapshot.'
589
+ : 'Only one event recorded — not enough timeline to replay.';
590
+ playerEl.appendChild(msg);
69
591
  }
70
592
 
593
+ // ---- Helpers -----------------------------------------------------------
71
594
  function el(tag, cls, text) {
72
595
  var n = document.createElement(tag);
73
596
  if (cls) n.className = cls;
@@ -75,37 +598,232 @@ const BOOTSTRAP = `
75
598
  return n;
76
599
  }
77
600
 
78
- function renderConsole(container, rows) {
79
- if (!rows.length) { container.appendChild(el('div', 'empty', 'No console output captured.')); return; }
601
+ // "+1:23.456" relative timestamp from the session start.
602
+ function fmtRelTs(ts, firstTs) {
603
+ if (!firstTs || !ts) return '+0:00.000';
604
+ var delta = Math.max(0, ts - firstTs);
605
+ var seconds = delta / 1000;
606
+ var minutes = Math.floor(seconds / 60);
607
+ var rem = seconds - minutes * 60;
608
+ var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
609
+ var ms = String(Math.floor((rem - Math.floor(rem)) * 1000));
610
+ while (ms.length < 3) ms = '0' + ms;
611
+ return '+' + minutes + ':' + pad(Math.floor(rem)) + '.' + ms;
612
+ }
613
+
614
+ // ---- Panel rendering ---------------------------------------------------
615
+ function renderConsole(container, rows, firstTs) {
616
+ if (!rows.length) {
617
+ container.appendChild(el('div', 'panel-empty', 'No console output captured.'));
618
+ return;
619
+ }
80
620
  for (var i = 0; i < rows.length; i++) {
81
621
  var r = rows[i];
82
- var row = el('div', 'row ' + (r.level || 'log'));
83
- row.appendChild(el('span', 'lvl', (r.level || 'log').toUpperCase()));
84
- row.appendChild(document.createTextNode(r.message));
622
+ var lvl = (r.level || 'log').toLowerCase();
623
+ var row = el('div', 'row is-future ' + lvl);
624
+ var relMs = firstTs ? Math.max(0, r.timestamp - firstTs) : 0;
625
+ row.setAttribute('data-time', String(relMs));
626
+ row.appendChild(el('span', 'ts', fmtRelTs(r.timestamp, firstTs)));
627
+ row.appendChild(el('span', 'lvl', lvl));
628
+ row.appendChild(el('span', 'msg', r.message));
629
+ row.setAttribute('data-level', lvl);
630
+ row.setAttribute('data-text', r.message.toLowerCase());
85
631
  container.appendChild(row);
86
632
  }
87
633
  }
88
634
 
89
- function renderNetwork(container, rows) {
90
- if (!rows.length) { container.appendChild(el('div', 'empty', 'No failed network requests captured.')); return; }
635
+ function renderNetwork(container, rows, firstTs) {
636
+ if (!rows.length) {
637
+ container.appendChild(el('div', 'panel-empty', 'No failed network requests captured.'));
638
+ return;
639
+ }
91
640
  for (var i = 0; i < rows.length; i++) {
92
641
  var r = rows[i];
93
- var row = el('div', 'row');
94
- row.appendChild(el('span', 'st st' + String(r.status).charAt(0), String(r.status)));
95
- row.appendChild(document.createTextNode((r.method ? r.method + ' ' : '') + r.url));
642
+ var row = el('div', 'row row-net is-future error');
643
+ var relMs = firstTs ? Math.max(0, r.timestamp - firstTs) : 0;
644
+ row.setAttribute('data-time', String(relMs));
645
+ row.appendChild(el('span', 'ts', fmtRelTs(r.timestamp, firstTs)));
646
+ var stCls = 'lvl st-' + String(r.status).charAt(0);
647
+ row.appendChild(el('span', stCls, String(r.status)));
648
+ var msg = el('span', 'msg');
649
+ if (r.method) {
650
+ var m = el('span', 'method', r.method);
651
+ msg.appendChild(m);
652
+ }
653
+ msg.appendChild(document.createTextNode(r.url));
654
+ row.appendChild(msg);
655
+ var text = (r.method || '') + ' ' + r.url + ' ' + String(r.status);
656
+ row.setAttribute('data-text', text.toLowerCase());
96
657
  container.appendChild(row);
97
658
  }
98
659
  }
99
660
 
100
- renderConsole(document.getElementById('console-rows'), CONSOLE);
101
- renderNetwork(document.getElementById('network-rows'), NETWORK);
661
+ renderConsole(document.getElementById('console-rows'), CONSOLE, FIRST_TS);
662
+ renderNetwork(document.getElementById('network-rows'), NETWORK, FIRST_TS);
663
+
664
+ // ---- Time-sync: reveal rows as playback advances ----------------------
665
+ function tickPanels(currentMs) {
666
+ var t = Math.max(0, Math.floor(currentMs || 0));
667
+ var names = ['console', 'network'];
668
+ for (var n = 0; n < names.length; n++) {
669
+ var name = names[n];
670
+ var container = document.getElementById(name + '-rows');
671
+ var pending = document.getElementById(name + '-pending');
672
+ var badgeCurrent = document.querySelector(
673
+ '.tab[data-pane="pane-' + name + '"] .count'
674
+ );
675
+ if (!container) continue;
676
+ var rows = container.querySelectorAll('.row');
677
+ // Detect "was at bottom" BEFORE flipping classes so auto-scroll only
678
+ // triggers when the user hasn't scrolled up to inspect older entries.
679
+ var wasAtBottom =
680
+ container.scrollHeight - container.scrollTop - container.clientHeight <= 20;
681
+ var visibleCount = 0;
682
+ var hasAnyRows = rows.length > 0;
683
+ for (var i = 0; i < rows.length; i++) {
684
+ var rowTime = parseInt(rows[i].getAttribute('data-time') || '0', 10);
685
+ var isFuture = rowTime > t;
686
+ rows[i].classList.toggle('is-future', isFuture);
687
+ if (!isFuture && !rows[i].classList.contains('hidden')) visibleCount++;
688
+ }
689
+ if (badgeCurrent) badgeCurrent.textContent = String(visibleCount);
690
+ if (pending) {
691
+ // Pending placeholder shows when rows exist but none visible yet.
692
+ pending.classList.toggle('is-hidden', !hasAnyRows || visibleCount > 0);
693
+ }
694
+ if (wasAtBottom && visibleCount > 0) {
695
+ container.scrollTop = container.scrollHeight;
696
+ }
697
+ }
698
+ }
699
+
700
+ if (rrPlayer && typeof rrPlayer.addEventListener === 'function') {
701
+ rrPlayer.addEventListener('ui-update-current-time', function (e) {
702
+ // rrweb-player's CustomEvent shape: { payload: <ms> } in alpha.4. If
703
+ // the bundled version ever changes shape, fall back to reading the
704
+ // replayer directly.
705
+ var payload = e && e.payload;
706
+ if (typeof payload !== 'number') {
707
+ try { payload = rrPlayer.getReplayer().getCurrentTime(); } catch (_) { payload = 0; }
708
+ }
709
+ tickPanels(payload);
710
+ });
711
+ }
712
+ tickPanels(0);
713
+
714
+ // ---- Click-to-seek: clicking a row jumps the player to that moment -----
715
+ if (rrPlayer && typeof rrPlayer.goto === 'function') {
716
+ var seekContainers = ['console-rows', 'network-rows'];
717
+ for (var sc = 0; sc < seekContainers.length; sc++) {
718
+ (function (containerId) {
719
+ var ctr = document.getElementById(containerId);
720
+ if (!ctr) return;
721
+ ctr.addEventListener('click', function (ev) {
722
+ var target = ev.target;
723
+ // Walk up to the .row ancestor (clicks may land on inner spans).
724
+ while (target && target !== ctr && !target.classList.contains('row')) {
725
+ target = target.parentNode;
726
+ }
727
+ if (!target || target === ctr) return;
728
+ var t = parseInt(target.getAttribute('data-time') || '0', 10);
729
+ if (isFinite(t) && t >= 0) {
730
+ rrPlayer.goto(t, false);
731
+ }
732
+ });
733
+ })(seekContainers[sc]);
734
+ }
735
+ }
736
+
737
+ // ---- Tab switching -----------------------------------------------------
738
+ var tabs = document.querySelectorAll('.tab');
739
+ var panes = document.querySelectorAll('.panel-pane');
740
+ for (var t = 0; t < tabs.length; t++) {
741
+ (function (tab) {
742
+ tab.addEventListener('click', function () {
743
+ var targetId = tab.getAttribute('data-pane');
744
+ for (var i = 0; i < tabs.length; i++) tabs[i].classList.remove('active');
745
+ for (var j = 0; j < panes.length; j++) {
746
+ panes[j].classList.toggle('active', panes[j].id === targetId);
747
+ }
748
+ tab.classList.add('active');
749
+ });
750
+ })(tabs[t]);
751
+ }
752
+
753
+ // ---- Per-pane filter input + level chips ------------------------------
754
+ function wireFilters(paneEl) {
755
+ var input = paneEl.querySelector('.panel-filter');
756
+ var chips = paneEl.querySelectorAll('.filter-chip');
757
+ var rows = paneEl.querySelectorAll('.row');
758
+
759
+ var activeLevels = {};
760
+ for (var c = 0; c < chips.length; c++) {
761
+ if (chips[c].classList.contains('active')) {
762
+ activeLevels[chips[c].getAttribute('data-level') || ''] = true;
763
+ }
764
+ }
765
+
766
+ function applyFilter() {
767
+ var q = (input ? input.value : '').trim().toLowerCase();
768
+ var anyActive = false;
769
+ for (var k in activeLevels) { if (activeLevels[k]) { anyActive = true; break; } }
770
+ for (var i = 0; i < rows.length; i++) {
771
+ var row = rows[i];
772
+ var text = row.getAttribute('data-text') || '';
773
+ var level = row.getAttribute('data-level') || '';
774
+ // Level filter: if any chips are active, row must match one of them.
775
+ // Network rows have no level — treated as "always shown" by level filter.
776
+ var levelOk = !anyActive || !level || activeLevels[level];
777
+ var textOk = !q || text.indexOf(q) !== -1;
778
+ row.classList.toggle('hidden', !(levelOk && textOk));
779
+ }
780
+ // Re-tick so the badge reflects (filter ∩ time-sync) visibility. Use
781
+ // the player's current time when available so we don't reset to t=0.
782
+ var nowMs = 0;
783
+ try { if (rrPlayer) nowMs = rrPlayer.getReplayer().getCurrentTime(); } catch (_) {}
784
+ tickPanels(nowMs);
785
+ }
786
+
787
+ if (input) input.addEventListener('input', applyFilter);
788
+ for (var c2 = 0; c2 < chips.length; c2++) {
789
+ (function (chip) {
790
+ chip.addEventListener('click', function () {
791
+ var lvl = chip.getAttribute('data-level') || '';
792
+ if (chip.classList.contains('active')) {
793
+ chip.classList.remove('active');
794
+ activeLevels[lvl] = false;
795
+ } else {
796
+ chip.classList.add('active');
797
+ activeLevels[lvl] = true;
798
+ }
799
+ applyFilter();
800
+ });
801
+ })(chips[c2]);
802
+ }
803
+ applyFilter();
804
+ }
102
805
 
103
- var btn = document.getElementById('copy-md');
104
- if (btn) {
105
- btn.addEventListener('click', function () {
106
- var done = function () { var t = btn.textContent; btn.textContent = 'Copied!'; setTimeout(function () { btn.textContent = t; }, 1500); };
806
+ for (var p = 0; p < panes.length; p++) wireFilters(panes[p]);
807
+
808
+ // ---- Copy-as-Markdown FAB ----------------------------------------------
809
+ var fab = document.getElementById('copy-md');
810
+ if (fab) {
811
+ var fabLabel = fab.querySelector('.label');
812
+ var resetTimer = null;
813
+ fab.addEventListener('click', function () {
814
+ var done = function () {
815
+ fab.classList.add('copied');
816
+ if (fabLabel) fabLabel.textContent = 'Copied to clipboard';
817
+ if (resetTimer) clearTimeout(resetTimer);
818
+ resetTimer = setTimeout(function () {
819
+ fab.classList.remove('copied');
820
+ if (fabLabel) fabLabel.textContent = 'Copy as Markdown for AI';
821
+ }, 2000);
822
+ };
107
823
  if (navigator.clipboard && navigator.clipboard.writeText) {
108
- navigator.clipboard.writeText(MARKDOWN).then(done, function () { window.prompt('Copy the Markdown below:', MARKDOWN); });
824
+ navigator.clipboard.writeText(MARKDOWN).then(done, function () {
825
+ window.prompt('Copy the Markdown below:', MARKDOWN);
826
+ });
109
827
  } else {
110
828
  window.prompt('Copy the Markdown below:', MARKDOWN);
111
829
  }
@@ -113,21 +831,47 @@ const BOOTSTRAP = `
113
831
  }
114
832
  })();
115
833
  `;
834
+ /**
835
+ * Self-marketing footer (Phase 5 indirect-virality artifact).
836
+ *
837
+ * Every report shared in a PR comment or attached to a JIRA ticket becomes a
838
+ * tracked acquisition channel — the Loom / Calendly / Statuspage compounding
839
+ * pattern. UTM params let us attribute click-through downstream; the link
840
+ * targets the repo's `packages/tracelane-wdio` directory because the install
841
+ * command (`npm i @tracelane/wdio`) is what we want a reader to see first
842
+ * (per the research's "link to the install command, not the marketing site"
843
+ * rule).
844
+ */
845
+ const FOOTER_HTML = '<footer class="attrib">' +
846
+ ' <em>Generated by</em> <a href="https://github.com/Cubenest/rrweb-stack/tree/main/packages/tracelane-wdio?utm_source=tracelane-report&utm_medium=html-footer&utm_campaign=indirect-virality" rel="noopener">tracelane</a> — self-contained HTML test-failure replays. No SaaS, no telemetry, no signup.' +
847
+ '</footer>';
848
+ function formatRelativeMs(ms) {
849
+ const s = ms / 1000;
850
+ const m = Math.floor(s / 60);
851
+ const r = Math.floor(s - m * 60);
852
+ return `${m}:${r < 10 ? '0' : ''}${r}`;
853
+ }
116
854
  /** Compose the full self-contained HTML document. */
117
855
  export function renderReportHtml(data) {
118
- const { meta, eventsGzB64, console: consoleRows, network, markdown, pruned } = data;
856
+ const { meta, eventsGzB64, console: consoleRows, network, markdown, pruned, eventCount, firstTs, lastTs, } = data;
119
857
  const title = `tracelane — ${meta.spec ?? '(no spec)'} :: ${meta.title} (${meta.status})`;
858
+ // Banner only when the events were pruned to fit the size cap (ADR-0005).
120
859
  const banner = pruned
121
860
  ? '<div class="banner">Some recorded events were pruned to fit the 25 MB report budget — replay may skip detail.</div>'
122
861
  : '';
862
+ const consoleCount = consoleRows.length;
863
+ const networkCount = network.length;
123
864
  // Data payloads embedded as JS consts, all escaped for inline-script safety.
124
- // The events blob is base64 (already inline-safe); the rest go through
125
- // serializeForScript to neutralise any `</script>` in user data.
126
865
  const dataScript = `const META = ${serializeForScript(meta)};\n` +
127
866
  `const EVENTS_GZ_B64 = "${eventsGzB64}";\n` +
128
867
  `const CONSOLE = ${serializeForScript(consoleRows)};\n` +
129
868
  `const NETWORK = ${serializeForScript(network)};\n` +
130
- `const MARKDOWN = ${serializeForScript(markdown)};`;
869
+ `const MARKDOWN = ${serializeForScript(markdown)};\n` +
870
+ `const FIRST_TS = ${firstTs};\n` +
871
+ `const LAST_TS = ${lastTs};`;
872
+ const sessionRangeText = firstTs && lastTs && lastTs > firstTs
873
+ ? `+0:00 → +${formatRelativeMs(lastTs - firstTs)} · ${eventCount.toLocaleString('en-US')} events`
874
+ : `${eventCount.toLocaleString('en-US')} events`;
131
875
  return `<!doctype html>
132
876
  <html lang="en">
133
877
  <head>
@@ -135,23 +879,81 @@ export function renderReportHtml(data) {
135
879
  <meta name="viewport" content="width=device-width, initial-scale=1">
136
880
  <title>${escapeHtml(title)}</title>
137
881
  <style>${loadPlayerCss()}</style>
138
- <style>${SHELL_CSS}</style>
882
+ <style>${buildShellCss()}</style>
139
883
  </head>
140
884
  <body>
141
- ${renderMetaHeader(meta)}
885
+ ${renderHero(meta, eventCount)}
142
886
  ${banner}
143
- <div class="toolbar"><button id="copy-md" class="copy-md" type="button">Copy as Markdown for AI paste</button></div>
144
- <main>
145
- <section id="player" aria-label="Session replay"></section>
146
- <aside id="panels">
147
- <section aria-label="Console"><h2>Console</h2><div id="console-rows" class="rows"></div></section>
148
- <section aria-label="Network"><h2>Network errors</h2><div id="network-rows" class="rows"></div></section>
149
- </aside>
887
+ <main class="investigation">
888
+ <section class="replay" aria-label="Session replay">
889
+ <div class="replay-header">
890
+ <h2>Replay</h2>
891
+ <span class="timestamp">${escapeHtml(sessionRangeText)}</span>
892
+ </div>
893
+ <div id="player" role="img" aria-label="rrweb player"></div>
894
+ </section>
895
+ <aside class="panels" aria-label="Investigation panels">
896
+ <div class="tabs" role="tablist">
897
+ <button class="tab active" type="button" role="tab" data-pane="pane-console">
898
+ Console <span class="count">0</span><span class="count-total">/ ${consoleCount}</span>
899
+ </button>
900
+ <button class="tab" type="button" role="tab" data-pane="pane-network">
901
+ Network <span class="count">0</span><span class="count-total">/ ${networkCount}</span>
902
+ </button>
903
+ <button class="tab" type="button" role="tab" data-pane="pane-actions">
904
+ Actions
905
+ </button>
906
+ <button class="tab" type="button" role="tab" data-pane="pane-timeline">
907
+ Timeline
908
+ </button>
909
+ </div>
910
+
911
+ <div class="panel-pane active" id="pane-console" role="tabpanel">
912
+ <div class="panel-toolbar">
913
+ <input type="text" class="panel-filter" placeholder="Filter console…" aria-label="Filter console messages" />
914
+ <button class="filter-chip" type="button" data-level="error">errors</button>
915
+ <button class="filter-chip" type="button" data-level="warn">warn</button>
916
+ </div>
917
+ <div id="console-rows" class="panel-content"></div>
918
+ <div class="panel-pending" id="console-pending">Console output will appear during playback.</div>
919
+ </div>
920
+
921
+ <div class="panel-pane" id="pane-network" role="tabpanel">
922
+ <div class="panel-toolbar">
923
+ <input type="text" class="panel-filter" placeholder="Filter URLs…" aria-label="Filter network requests" />
924
+ </div>
925
+ <div id="network-rows" class="panel-content"></div>
926
+ <div class="panel-pending" id="network-pending">Network errors will appear during playback.</div>
927
+ </div>
928
+
929
+ <div class="panel-pane" id="pane-actions" role="tabpanel">
930
+ <div class="coming-soon">
931
+ <strong>Actions panel — coming soon</strong>
932
+ <span>User-input event extraction lands in a follow-up changeset.</span>
933
+ <span>Use the rrweb-player scrubber to walk through actions manually.</span>
934
+ </div>
935
+ </div>
936
+
937
+ <div class="panel-pane" id="pane-timeline" role="tabpanel">
938
+ <div class="coming-soon">
939
+ <strong>Timeline panel — coming soon</strong>
940
+ <span>Use the rrweb-player scrubber above to navigate the recording today.</span>
941
+ <span>A richer event-by-event timeline lands in a follow-up changeset.</span>
942
+ </div>
943
+ </div>
944
+ </aside>
150
945
  </main>
946
+
947
+ <button class="fab" id="copy-md" type="button" aria-label="Copy report as Markdown for AI">
948
+ <span class="icon" aria-hidden="true"></span>
949
+ <span class="label">Copy as Markdown for AI</span>
950
+ </button>
951
+
151
952
  <script>${loadFflateGunzipSource()}</script>
152
953
  <script>${loadPlayerUmd()}</script>
153
954
  <script>${dataScript}</script>
154
955
  <script>${BOOTSTRAP}</script>
956
+ ${FOOTER_HTML}
155
957
  </body>
156
958
  </html>`;
157
959
  }