@tracelane/report 0.1.0-alpha.2 → 0.1.0-alpha.20

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 (67) hide show
  1. package/NOTICE +8 -0
  2. package/README.md +33 -5
  3. package/dist/_security/detectors/insecure-cookies.d.ts +4 -0
  4. package/dist/_security/detectors/insecure-cookies.d.ts.map +1 -0
  5. package/dist/_security/detectors/insecure-cookies.js +32 -0
  6. package/dist/_security/detectors/insecure-cookies.js.map +1 -0
  7. package/dist/_security/detectors/missing-headers.d.ts +4 -0
  8. package/dist/_security/detectors/missing-headers.d.ts.map +1 -0
  9. package/dist/_security/detectors/missing-headers.js +36 -0
  10. package/dist/_security/detectors/missing-headers.js.map +1 -0
  11. package/dist/_security/detectors/mixed-content.d.ts +5 -0
  12. package/dist/_security/detectors/mixed-content.d.ts.map +1 -0
  13. package/dist/_security/detectors/mixed-content.js +74 -0
  14. package/dist/_security/detectors/mixed-content.js.map +1 -0
  15. package/dist/_security/detectors/reverse-tabnabbing.d.ts +10 -0
  16. package/dist/_security/detectors/reverse-tabnabbing.d.ts.map +1 -0
  17. package/dist/_security/detectors/reverse-tabnabbing.js +44 -0
  18. package/dist/_security/detectors/reverse-tabnabbing.js.map +1 -0
  19. package/dist/_security/index.d.ts +29 -0
  20. package/dist/_security/index.d.ts.map +1 -0
  21. package/dist/_security/index.js +36 -0
  22. package/dist/_security/index.js.map +1 -0
  23. package/dist/_security/response-meta.d.ts +28 -0
  24. package/dist/_security/response-meta.d.ts.map +1 -0
  25. package/dist/_security/response-meta.js +50 -0
  26. package/dist/_security/response-meta.js.map +1 -0
  27. package/dist/_security/serialized-dom.d.ts +20 -0
  28. package/dist/_security/serialized-dom.d.ts.map +1 -0
  29. package/dist/_security/serialized-dom.js +32 -0
  30. package/dist/_security/serialized-dom.js.map +1 -0
  31. package/dist/_security/suppress.d.ts +7 -0
  32. package/dist/_security/suppress.d.ts.map +1 -0
  33. package/dist/_security/suppress.js +8 -0
  34. package/dist/_security/suppress.js.map +1 -0
  35. package/dist/assets.d.ts +20 -0
  36. package/dist/assets.d.ts.map +1 -1
  37. package/dist/assets.js +60 -6
  38. package/dist/assets.js.map +1 -1
  39. package/dist/build-report.d.ts +19 -0
  40. package/dist/build-report.d.ts.map +1 -1
  41. package/dist/build-report.js +20 -2
  42. package/dist/build-report.js.map +1 -1
  43. package/dist/index.d.ts +3 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +3 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/markdown.d.ts +2 -1
  48. package/dist/markdown.d.ts.map +1 -1
  49. package/dist/markdown.js +11 -1
  50. package/dist/markdown.js.map +1 -1
  51. package/dist/metadata.d.ts +21 -2
  52. package/dist/metadata.d.ts.map +1 -1
  53. package/dist/metadata.js +98 -27
  54. package/dist/metadata.js.map +1 -1
  55. package/dist/panels.d.ts +40 -5
  56. package/dist/panels.d.ts.map +1 -1
  57. package/dist/panels.js +175 -23
  58. package/dist/panels.js.map +1 -1
  59. package/dist/report-writer.d.ts +43 -0
  60. package/dist/report-writer.d.ts.map +1 -0
  61. package/dist/report-writer.js +65 -0
  62. package/dist/report-writer.js.map +1 -0
  63. package/dist/template.d.ts +19 -0
  64. package/dist/template.d.ts.map +1 -1
  65. package/dist/template.js +987 -72
  66. package/dist/template.js.map +1 -1
  67. package/package.json +28 -7
package/dist/template.js CHANGED
@@ -1,51 +1,582 @@
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
+ /* "soon" pill on the placeholder Actions / Timeline tabs (audit A-10) — warns
309
+ the visitor the pane is a coming-soon stub before they click it. */
310
+ .tab .soon-pill {
311
+ margin-left: 6px;
312
+ padding: 1px 6px;
313
+ border-radius: 8px;
314
+ border: 1px solid var(--border-strong);
315
+ background: transparent;
316
+ color: var(--muted);
317
+ font-size: 9px;
318
+ font-weight: 600;
319
+ text-transform: uppercase;
320
+ letter-spacing: 0.08em;
321
+ vertical-align: middle;
322
+ }
323
+
324
+ .panel-toolbar {
325
+ padding: 10px 16px;
326
+ border-bottom: 1px solid var(--border);
327
+ display: flex;
328
+ gap: 12px;
329
+ align-items: center;
330
+ flex-wrap: wrap;
331
+ }
332
+ .panel-filter {
333
+ flex: 1;
334
+ min-width: 160px;
335
+ background: var(--bg);
336
+ border: 1px solid var(--border);
337
+ border-radius: 4px;
338
+ padding: 6px 12px;
339
+ color: var(--text);
340
+ font-family: var(--mono);
341
+ font-size: 12px;
342
+ transition: border-color 0.15s ease;
343
+ }
344
+ .panel-filter:focus { outline: none; border-color: var(--teal); }
345
+ .panel-filter::placeholder { color: var(--muted); }
346
+ .filter-chip {
347
+ padding: 4px 10px;
348
+ border-radius: 12px;
349
+ border: 1px solid var(--border);
350
+ font-family: var(--mono);
351
+ font-size: 10px;
352
+ text-transform: uppercase;
353
+ letter-spacing: 0.08em;
354
+ color: var(--muted);
355
+ background: transparent;
356
+ cursor: pointer;
357
+ transition: all 0.15s ease;
358
+ }
359
+ .filter-chip:hover { border-color: var(--border-strong); color: var(--muted-strong); }
360
+ .filter-chip.active {
361
+ border-color: var(--amber);
362
+ color: var(--amber);
363
+ background: var(--amber-dim);
364
+ }
365
+
366
+ .panel-pane { display: none; flex-direction: column; min-height: 0; flex: 1; }
367
+ .panel-pane.active { display: flex; }
368
+
369
+ .panel-content {
370
+ flex: 1;
371
+ overflow-y: auto;
372
+ padding: 8px 0;
373
+ }
374
+ .panel-empty {
375
+ padding: 24px 16px;
376
+ color: var(--muted);
377
+ font-style: italic;
378
+ text-align: center;
379
+ font-size: 12px;
380
+ }
381
+
382
+ /* Time-sync: rows whose data-time is past the current playback time. */
383
+ .panel-content .row.is-future { display: none; }
384
+ .panel-content .row { cursor: pointer; }
385
+
386
+ /* Pending placeholder shown when rows exist but the playhead hasn't reached
387
+ any of them yet. Sibling of .panel-content inside .panel-pane. */
388
+ .panel-pending {
389
+ padding: 24px;
390
+ text-align: center;
391
+ color: var(--muted);
392
+ font-family: var(--mono);
393
+ font-size: 12px;
394
+ letter-spacing: 0.02em;
395
+ }
396
+ .panel-pending.is-hidden { display: none; }
397
+
398
+ /* Tab badge: <current> in default color, "/ <total>" in --muted. */
399
+ .tab .count-total {
400
+ color: var(--muted);
401
+ margin-left: 4px;
402
+ font-weight: normal;
403
+ }
404
+ .row {
405
+ padding: 8px 16px;
406
+ border-bottom: 1px solid var(--border);
407
+ display: grid;
408
+ grid-template-columns: max-content max-content 1fr;
409
+ gap: 14px;
410
+ font-family: var(--mono);
411
+ font-size: 11.5px;
412
+ line-height: 1.55;
413
+ transition: background 0.1s ease;
414
+ }
415
+ .row.error { background: rgba(245, 163, 100, 0.04); }
416
+ .row.hidden { display: none; }
417
+ .row .ts { color: var(--muted); font-size: 10.5px; padding-top: 1px; white-space: nowrap; }
418
+ .row .lvl {
419
+ font-weight: 700;
420
+ font-size: 10px;
421
+ text-transform: uppercase;
422
+ letter-spacing: 0.08em;
423
+ padding-top: 2px;
424
+ white-space: nowrap;
425
+ }
426
+ .row.error .lvl { color: var(--amber); }
427
+ .row.warn .lvl { color: var(--warn); }
428
+ .row.log .lvl,
429
+ .row.info .lvl,
430
+ .row.debug .lvl { color: var(--muted); }
431
+ .row .msg { color: var(--text); word-break: break-word; white-space: pre-wrap; }
432
+ .row .msg .method { color: var(--muted-strong); font-weight: 600; margin-right: 6px; }
433
+ .row.row-net .lvl.st-4,
434
+ .row.row-net .lvl.st-5 { color: var(--amber); }
435
+ .row.row-net .lvl.st-0 { color: var(--amber); } /* net error */
436
+
437
+ /* Advisory security findings: not time-synced (no seek), so not clickable. */
438
+ .row.row-sec { cursor: default; grid-template-columns: max-content 1fr; }
439
+ .row.row-sec.sev-high .lvl { color: var(--amber); }
440
+ .row.row-sec.sev-medium .lvl { color: var(--warn); }
441
+ .row.row-sec.sev-low .lvl { color: var(--muted-strong); }
442
+ .row.row-sec .sec-detail {
443
+ color: var(--muted);
444
+ font-size: 10.5px;
445
+ margin-top: 3px;
446
+ white-space: pre-wrap;
447
+ }
448
+
449
+ /* Security panel subtitle (advisory framing) shown in its toolbar. */
450
+ .panel-toolbar-sec { flex-direction: column; align-items: stretch; gap: 8px; }
451
+ .panel-subtitle {
452
+ color: var(--muted);
453
+ font-size: 11px;
454
+ font-style: italic;
455
+ line-height: 1.5;
456
+ }
457
+
458
+ /* "Coming soon" pane content (Actions / Timeline tabs reserved for follow-ups) */
459
+ .coming-soon {
460
+ padding: 32px 16px;
461
+ text-align: center;
462
+ color: var(--muted);
463
+ font-family: var(--mono);
464
+ font-size: 12px;
465
+ display: flex;
466
+ flex-direction: column;
467
+ gap: 6px;
468
+ }
469
+ .coming-soon strong { color: var(--text); font-weight: 600; }
470
+
471
+ /* Floating action button: Copy as Markdown */
472
+ .fab {
473
+ position: fixed;
474
+ right: 28px;
475
+ bottom: 28px;
476
+ z-index: 100;
477
+ display: flex;
478
+ align-items: center;
479
+ gap: 10px;
480
+ padding: 14px 22px;
481
+ background: var(--surface);
482
+ border: 1px solid var(--border-strong);
483
+ border-radius: 999px;
484
+ color: var(--text);
485
+ font-family: var(--mono);
486
+ font-size: 12px;
487
+ font-weight: 500;
488
+ cursor: pointer;
489
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(94, 234, 212, 0.06);
490
+ transition: all 0.2s ease;
491
+ }
492
+ .fab:hover {
493
+ border-color: var(--teal);
494
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(94, 234, 212, 0.3);
495
+ transform: translateY(-1px);
496
+ }
497
+ .fab.copied {
498
+ border-color: var(--teal);
499
+ color: var(--teal);
500
+ box-shadow: 0 0 0 4px var(--teal-dim), 0 8px 32px rgba(0, 0, 0, 0.4);
501
+ }
502
+ .fab .icon {
503
+ width: 14px; height: 14px;
504
+ border: 1.5px solid var(--teal);
505
+ border-radius: 3px;
506
+ position: relative;
507
+ flex-shrink: 0;
508
+ }
509
+ .fab .icon::after {
510
+ content: '';
511
+ position: absolute;
512
+ top: -3px; left: 2px;
513
+ width: 10px; height: 11px;
514
+ border: 1.5px solid var(--teal);
515
+ border-radius: 2px;
516
+ background: var(--surface);
517
+ transition: opacity 0.2s ease;
518
+ }
519
+ .fab.copied .icon { border-color: var(--teal); }
520
+ .fab.copied .icon::after { opacity: 0; }
521
+ .fab.copied .icon::before {
522
+ content: '';
523
+ position: absolute;
524
+ top: 1px; left: 4px;
525
+ width: 4px; height: 8px;
526
+ border-right: 1.5px solid var(--teal);
527
+ border-bottom: 1.5px solid var(--teal);
528
+ transform: rotate(45deg);
529
+ }
530
+
531
+ /* Footer */
532
+ footer.attrib {
533
+ padding: 32px 48px;
534
+ border-top: 1px solid var(--border);
535
+ text-align: center;
536
+ font-family: var(--mono);
537
+ font-size: 11px;
538
+ color: var(--muted);
539
+ }
540
+ footer.attrib a { color: var(--muted-strong); }
541
+ footer.attrib em {
542
+ font-family: var(--serif);
543
+ font-style: italic;
544
+ font-size: 12px;
545
+ color: var(--muted-strong);
546
+ }
547
+
548
+ /* Mobile: stack the replay above panels; panels become full-width. */
549
+ @media (max-width: 900px) {
550
+ .hero { padding: 32px 24px 24px; }
551
+ .meta-strip { margin: 16px -24px -24px; padding: 12px 24px; gap: 4px 0; }
552
+ .meta-strip .item { border-right: 0; padding: 4px 0; margin-right: 24px; }
553
+ .banner { padding: 10px 24px; }
554
+ main.investigation { grid-template-columns: 1fr; }
555
+ .replay { border-right: 0; border-bottom: 1px solid var(--border); padding: 20px 24px; }
556
+ .panels { min-height: 50vh; }
557
+ .fab { right: 16px; bottom: 16px; padding: 12px 18px; font-size: 11px; }
558
+ h1.what { font-size: 28px; }
559
+ footer.attrib { padding: 24px 24px; }
560
+ }
561
+
562
+ /* rrweb-player wrapper: ensure it fills #player without overflow weirdness.
563
+ The controller has a white background; default text inherits our dark-theme
564
+ --text (near-white), making 2x/4x/8x speed buttons and the "skip inactive"
565
+ label invisible. Restore a dark text color scoped to the player controls. */
566
+ #player .rr-player { background: var(--surface) !important; }
567
+ #player .rr-controller__btns button { color: #11103e; }
568
+ #player .rr-controller__btns button.active { color: #fff; }
569
+ #player .rr-controller .switch .label { color: #11103e; }
42
570
  `;
571
+ return fontFaces + styles;
572
+ }
43
573
  /**
44
574
  * The in-page bootstrap (runs at view time, plain ES5-ish JS so it executes in
45
575
  * any browser without a build step). Reads the embedded payloads, decompresses
46
576
  * 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.
577
+ * wires up tab switching + filter input + filter chips, and animates the
578
+ * Copy-as-Markdown FAB. Authored as a single string so it ships verbatim in a
579
+ * `<script>` — it must not reference any TS/Node symbol.
49
580
  */
50
581
  const BOOTSTRAP = `
51
582
  (function () {
@@ -53,21 +584,49 @@ const BOOTSTRAP = `
53
584
  var bin = atob(b64);
54
585
  var bytes = new Uint8Array(bin.length);
55
586
  for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
587
+ if (bytes.length === 0) return [];
56
588
  var json = fflate.strFromU8(fflate.gunzipSync(bytes));
57
589
  return JSON.parse(json);
58
590
  }
59
591
 
60
592
  var events = decodeEvents(EVENTS_GZ_B64);
61
593
 
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.
594
+ // ---- rrweb-player mount ------------------------------------------------
64
595
  var playerEl = document.getElementById('player');
596
+ var rrPlayer = null;
65
597
  if (events.length >= 2 && typeof rrwebPlayer !== 'undefined') {
66
- new rrwebPlayer({ target: playerEl, props: { events: events, showController: true, autoPlay: false } });
598
+ // Size the player to fill its container, preserving the recording's
599
+ // aspect ratio. rrweb-player's defaults (1024x576) overflow most layouts.
600
+ var recAspect = (META.viewport && META.viewport.width && META.viewport.height)
601
+ ? META.viewport.width / META.viewport.height
602
+ : 16 / 10;
603
+ var containerW = playerEl.clientWidth || 1024;
604
+ var maxIframeH = Math.max(window.innerHeight - 360, 280);
605
+ var iframeH = Math.min(Math.round(containerW / recAspect), maxIframeH);
606
+ var iframeW = Math.min(containerW, Math.round(iframeH * recAspect));
607
+ rrPlayer = new rrwebPlayer({
608
+ target: playerEl,
609
+ props: {
610
+ events: events,
611
+ width: iframeW,
612
+ height: iframeH,
613
+ showController: true,
614
+ autoPlay: false,
615
+ },
616
+ });
617
+ // Expose for headless probes (visible to dev-tools; no runtime impact on
618
+ // end users — the report HTML is read-only static content).
619
+ try { window.__tracelanePlayer = rrPlayer; } catch (_) {}
67
620
  } else {
68
- playerEl.innerHTML = '<p class="empty">Not enough recorded events to replay.</p>';
621
+ var msg = document.createElement('div');
622
+ msg.className = 'empty';
623
+ msg.textContent = events.length === 0
624
+ ? 'No recorded events — the test crashed before the recorder produced a snapshot.'
625
+ : 'Only one event recorded — not enough timeline to replay.';
626
+ playerEl.appendChild(msg);
69
627
  }
70
628
 
629
+ // ---- Helpers -----------------------------------------------------------
71
630
  function el(tag, cls, text) {
72
631
  var n = document.createElement(tag);
73
632
  if (cls) n.className = cls;
@@ -75,37 +634,287 @@ const BOOTSTRAP = `
75
634
  return n;
76
635
  }
77
636
 
78
- function renderConsole(container, rows) {
79
- if (!rows.length) { container.appendChild(el('div', 'empty', 'No console output captured.')); return; }
637
+ // "+1:23.456" relative timestamp from the session start.
638
+ function fmtRelTs(ts, firstTs) {
639
+ if (!firstTs || !ts) return '+0:00.000';
640
+ var delta = Math.max(0, ts - firstTs);
641
+ var seconds = delta / 1000;
642
+ var minutes = Math.floor(seconds / 60);
643
+ var rem = seconds - minutes * 60;
644
+ var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
645
+ var ms = String(Math.floor((rem - Math.floor(rem)) * 1000));
646
+ while (ms.length < 3) ms = '0' + ms;
647
+ return '+' + minutes + ':' + pad(Math.floor(rem)) + '.' + ms;
648
+ }
649
+
650
+ // ---- Panel rendering ---------------------------------------------------
651
+ function renderConsole(container, rows, firstTs) {
652
+ if (!rows.length) {
653
+ container.appendChild(el('div', 'panel-empty', 'No console output captured.'));
654
+ return;
655
+ }
80
656
  for (var i = 0; i < rows.length; i++) {
81
657
  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));
658
+ var lvl = (r.level || 'log').toLowerCase();
659
+ var row = el('div', 'row is-future ' + lvl);
660
+ var relMs = firstTs ? Math.max(0, r.timestamp - firstTs) : 0;
661
+ row.setAttribute('data-time', String(relMs));
662
+ // a11y: rows are click-to-seek; expose them to keyboard + AT users.
663
+ row.setAttribute('tabindex', '0');
664
+ row.setAttribute('role', 'button');
665
+ row.setAttribute('aria-label', 'Seek to ' + fmtRelTs(r.timestamp, firstTs));
666
+ row.appendChild(el('span', 'ts', fmtRelTs(r.timestamp, firstTs)));
667
+ row.appendChild(el('span', 'lvl', lvl));
668
+ row.appendChild(el('span', 'msg', r.message));
669
+ row.setAttribute('data-level', lvl);
670
+ row.setAttribute('data-text', r.message.toLowerCase());
85
671
  container.appendChild(row);
86
672
  }
87
673
  }
88
674
 
89
- function renderNetwork(container, rows) {
90
- if (!rows.length) { container.appendChild(el('div', 'empty', 'No failed network requests captured.')); return; }
675
+ function renderNetwork(container, rows, firstTs) {
676
+ if (!rows.length) {
677
+ container.appendChild(el('div', 'panel-empty', 'No failed network requests captured.'));
678
+ return;
679
+ }
91
680
  for (var i = 0; i < rows.length; i++) {
92
681
  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));
682
+ var row = el('div', 'row row-net is-future error');
683
+ var relMs = firstTs ? Math.max(0, r.timestamp - firstTs) : 0;
684
+ row.setAttribute('data-time', String(relMs));
685
+ // a11y: rows are click-to-seek; expose them to keyboard + AT users.
686
+ row.setAttribute('tabindex', '0');
687
+ row.setAttribute('role', 'button');
688
+ row.setAttribute('aria-label', 'Seek to ' + fmtRelTs(r.timestamp, firstTs));
689
+ row.appendChild(el('span', 'ts', fmtRelTs(r.timestamp, firstTs)));
690
+ var stCls = 'lvl st-' + String(r.status).charAt(0);
691
+ row.appendChild(el('span', stCls, String(r.status)));
692
+ var msg = el('span', 'msg');
693
+ if (r.method) {
694
+ var m = el('span', 'method', r.method);
695
+ msg.appendChild(m);
696
+ }
697
+ msg.appendChild(document.createTextNode(r.url));
698
+ row.appendChild(msg);
699
+ var text = (r.method || '') + ' ' + r.url + ' ' + String(r.status);
700
+ row.setAttribute('data-text', text.toLowerCase());
701
+ container.appendChild(row);
702
+ }
703
+ }
704
+
705
+ // Advisory security-hygiene findings (Task 12). Unlike console/network rows
706
+ // these are NOT time-synced (a finding has no single moment in the replay) —
707
+ // each renders as a static, always-visible row: "[severity] title — evidence"
708
+ // with the detail as muted secondary text. The container is only present in
709
+ // the DOM when there are findings (the tab + pane are omitted when empty).
710
+ function renderSecurity(container, findings) {
711
+ if (!container) return;
712
+ if (!findings.length) {
713
+ container.appendChild(el('div', 'panel-empty', 'No advisory security findings.'));
714
+ return;
715
+ }
716
+ for (var i = 0; i < findings.length; i++) {
717
+ var f = findings[i];
718
+ var sev = (f.severity || 'low').toLowerCase();
719
+ var row = el('div', 'row row-sec sev-' + sev);
720
+ row.appendChild(el('span', 'lvl', sev));
721
+ var msg = el('span', 'msg');
722
+ msg.appendChild(document.createTextNode(f.title + ' — ' + f.evidence));
723
+ if (f.detail) {
724
+ msg.appendChild(el('div', 'sec-detail', f.detail));
725
+ }
726
+ row.appendChild(msg);
727
+ var text = (sev + ' ' + f.title + ' ' + f.evidence + ' ' + (f.detail || '')).toLowerCase();
728
+ row.setAttribute('data-text', text);
96
729
  container.appendChild(row);
97
730
  }
98
731
  }
99
732
 
100
- renderConsole(document.getElementById('console-rows'), CONSOLE);
101
- renderNetwork(document.getElementById('network-rows'), NETWORK);
733
+ renderConsole(document.getElementById('console-rows'), CONSOLE, FIRST_TS);
734
+ renderNetwork(document.getElementById('network-rows'), NETWORK, FIRST_TS);
735
+ renderSecurity(document.getElementById('security-rows'), SECURITY);
736
+
737
+ // ---- Time-sync: reveal rows as playback advances ----------------------
738
+ // TODO(perf): re-queries DOM and re-parses data-time on every tick (~30 Hz).
739
+ // Fine at current demo scale (~10 rows). When Actions / Timeline panels ship
740
+ // and reports start carrying hundreds of entries, cache [rows, times] once
741
+ // after render and skip work when t === lastT.
742
+ function tickPanels(currentMs) {
743
+ var t = Math.max(0, Math.floor(currentMs || 0));
744
+ var names = ['console', 'network'];
745
+ for (var n = 0; n < names.length; n++) {
746
+ var name = names[n];
747
+ var container = document.getElementById(name + '-rows');
748
+ var pending = document.getElementById(name + '-pending');
749
+ var badgeCurrent = document.querySelector(
750
+ '.tab[data-pane="pane-' + name + '"] .count'
751
+ );
752
+ if (!container) continue;
753
+ var rows = container.querySelectorAll('.row');
754
+ // Detect "was at bottom" BEFORE flipping classes so auto-scroll only
755
+ // triggers when the user hasn't scrolled up to inspect older entries.
756
+ var wasAtBottom =
757
+ container.scrollHeight - container.scrollTop - container.clientHeight <= 20;
758
+ var visibleCount = 0;
759
+ var hasAnyRows = rows.length > 0;
760
+ for (var i = 0; i < rows.length; i++) {
761
+ var rowTime = parseInt(rows[i].getAttribute('data-time') || '0', 10);
762
+ var isFuture = rowTime > t;
763
+ rows[i].classList.toggle('is-future', isFuture);
764
+ if (!isFuture && !rows[i].classList.contains('hidden')) visibleCount++;
765
+ }
766
+ if (badgeCurrent) badgeCurrent.textContent = String(visibleCount);
767
+ if (pending) {
768
+ // Pending placeholder shows when rows exist but none visible yet.
769
+ pending.classList.toggle('is-hidden', !hasAnyRows || visibleCount > 0);
770
+ }
771
+ if (wasAtBottom && visibleCount > 0) {
772
+ container.scrollTop = container.scrollHeight;
773
+ }
774
+ }
775
+ }
776
+
777
+ if (rrPlayer && typeof rrPlayer.addEventListener === 'function') {
778
+ rrPlayer.addEventListener('ui-update-current-time', function (e) {
779
+ // rrweb-player's CustomEvent shape: { payload: <ms> } in alpha.4. If
780
+ // the bundled version ever changes shape, fall back to reading the
781
+ // replayer directly.
782
+ var payload = e && e.payload;
783
+ if (typeof payload !== 'number') {
784
+ try { payload = rrPlayer.getReplayer().getCurrentTime(); } catch (_) { payload = 0; }
785
+ }
786
+ tickPanels(payload);
787
+ });
788
+ }
789
+ tickPanels(0);
790
+
791
+ // ---- Click/keyboard-to-seek: activating a row jumps the player ---------
792
+ // Rows are role="button" tabindex="0", so they're keyboard-focusable; Enter
793
+ // and Space activate them the same way a click does (a11y — audit A-7).
794
+ if (rrPlayer && typeof rrPlayer.goto === 'function') {
795
+ var seekContainers = ['console-rows', 'network-rows'];
796
+ var seekFromEvent = function (ctr, ev) {
797
+ var target = ev.target;
798
+ // Walk up to the .row ancestor (events may land on inner spans).
799
+ while (target && target !== ctr && !target.classList.contains('row')) {
800
+ target = target.parentNode;
801
+ }
802
+ if (!target || target === ctr) return;
803
+ var t = parseInt(target.getAttribute('data-time') || '0', 10);
804
+ if (isFinite(t) && t >= 0) {
805
+ rrPlayer.goto(t, false);
806
+ }
807
+ };
808
+ for (var sc = 0; sc < seekContainers.length; sc++) {
809
+ (function (containerId) {
810
+ var ctr = document.getElementById(containerId);
811
+ if (!ctr) return;
812
+ ctr.addEventListener('click', function (ev) {
813
+ seekFromEvent(ctr, ev);
814
+ });
815
+ ctr.addEventListener('keydown', function (ev) {
816
+ if (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'Spacebar') {
817
+ ev.preventDefault();
818
+ seekFromEvent(ctr, ev);
819
+ }
820
+ });
821
+ })(seekContainers[sc]);
822
+ }
823
+ }
824
+
825
+ // ---- Tab switching -----------------------------------------------------
826
+ var tabs = document.querySelectorAll('.tab');
827
+ var panes = document.querySelectorAll('.panel-pane');
828
+ for (var t = 0; t < tabs.length; t++) {
829
+ (function (tab) {
830
+ tab.addEventListener('click', function () {
831
+ var targetId = tab.getAttribute('data-pane');
832
+ for (var i = 0; i < tabs.length; i++) {
833
+ tabs[i].classList.remove('active');
834
+ tabs[i].setAttribute('aria-selected', tabs[i] === tab ? 'true' : 'false');
835
+ }
836
+ for (var j = 0; j < panes.length; j++) {
837
+ panes[j].classList.toggle('active', panes[j].id === targetId);
838
+ }
839
+ tab.classList.add('active');
840
+ });
841
+ })(tabs[t]);
842
+ }
843
+
844
+ // ---- Per-pane filter input + level chips ------------------------------
845
+ function wireFilters(paneEl) {
846
+ var input = paneEl.querySelector('.panel-filter');
847
+ var chips = paneEl.querySelectorAll('.filter-chip');
848
+ var rows = paneEl.querySelectorAll('.row');
849
+
850
+ var activeLevels = {};
851
+ for (var c = 0; c < chips.length; c++) {
852
+ if (chips[c].classList.contains('active')) {
853
+ activeLevels[chips[c].getAttribute('data-level') || ''] = true;
854
+ }
855
+ }
856
+
857
+ function applyFilter() {
858
+ var q = (input ? input.value : '').trim().toLowerCase();
859
+ var anyActive = false;
860
+ for (var k in activeLevels) { if (activeLevels[k]) { anyActive = true; break; } }
861
+ for (var i = 0; i < rows.length; i++) {
862
+ var row = rows[i];
863
+ var text = row.getAttribute('data-text') || '';
864
+ var level = row.getAttribute('data-level') || '';
865
+ // Level filter: if any chips are active, row must match one of them.
866
+ // Network rows have no level — treated as "always shown" by level filter.
867
+ var levelOk = !anyActive || !level || activeLevels[level];
868
+ var textOk = !q || text.indexOf(q) !== -1;
869
+ row.classList.toggle('hidden', !(levelOk && textOk));
870
+ }
871
+ // Re-tick so the badge reflects (filter ∩ time-sync) visibility. Use
872
+ // the player's current time when available so we don't reset to t=0.
873
+ var nowMs = 0;
874
+ try { if (rrPlayer) nowMs = rrPlayer.getReplayer().getCurrentTime(); } catch (_) {}
875
+ tickPanels(nowMs);
876
+ }
877
+
878
+ if (input) input.addEventListener('input', applyFilter);
879
+ for (var c2 = 0; c2 < chips.length; c2++) {
880
+ (function (chip) {
881
+ chip.addEventListener('click', function () {
882
+ var lvl = chip.getAttribute('data-level') || '';
883
+ if (chip.classList.contains('active')) {
884
+ chip.classList.remove('active');
885
+ activeLevels[lvl] = false;
886
+ } else {
887
+ chip.classList.add('active');
888
+ activeLevels[lvl] = true;
889
+ }
890
+ applyFilter();
891
+ });
892
+ })(chips[c2]);
893
+ }
894
+ applyFilter();
895
+ }
896
+
897
+ for (var p = 0; p < panes.length; p++) wireFilters(panes[p]);
102
898
 
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); };
899
+ // ---- Copy-as-Markdown FAB ----------------------------------------------
900
+ var fab = document.getElementById('copy-md');
901
+ if (fab) {
902
+ var fabLabel = fab.querySelector('.label');
903
+ var resetTimer = null;
904
+ fab.addEventListener('click', function () {
905
+ var done = function () {
906
+ fab.classList.add('copied');
907
+ if (fabLabel) fabLabel.textContent = 'Copied to clipboard';
908
+ if (resetTimer) clearTimeout(resetTimer);
909
+ resetTimer = setTimeout(function () {
910
+ fab.classList.remove('copied');
911
+ if (fabLabel) fabLabel.textContent = 'Copy as Markdown for AI';
912
+ }, 2000);
913
+ };
107
914
  if (navigator.clipboard && navigator.clipboard.writeText) {
108
- navigator.clipboard.writeText(MARKDOWN).then(done, function () { window.prompt('Copy the Markdown below:', MARKDOWN); });
915
+ navigator.clipboard.writeText(MARKDOWN).then(done, function () {
916
+ window.prompt('Copy the Markdown below:', MARKDOWN);
917
+ });
109
918
  } else {
110
919
  window.prompt('Copy the Markdown below:', MARKDOWN);
111
920
  }
@@ -113,21 +922,66 @@ const BOOTSTRAP = `
113
922
  }
114
923
  })();
115
924
  `;
925
+ /**
926
+ * Self-marketing footer (Phase 5 indirect-virality artifact).
927
+ *
928
+ * Every report shared in a PR comment or attached to a JIRA ticket becomes a
929
+ * tracked acquisition channel — the Loom / Calendly / Statuspage compounding
930
+ * pattern. UTM params let us attribute click-through downstream; the link
931
+ * targets the repo's `packages/tracelane-wdio` directory because the install
932
+ * command (`npm i @tracelane/wdio`) is what we want a reader to see first
933
+ * (per the research's "link to the install command, not the marketing site"
934
+ * rule).
935
+ */
936
+ const FOOTER_HTML = '<footer class="attrib">' +
937
+ ' <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.' +
938
+ '</footer>';
939
+ function formatRelativeMs(ms) {
940
+ const s = ms / 1000;
941
+ const m = Math.floor(s / 60);
942
+ const r = Math.floor(s - m * 60);
943
+ return `${m}:${r < 10 ? '0' : ''}${r}`;
944
+ }
116
945
  /** Compose the full self-contained HTML document. */
117
946
  export function renderReportHtml(data) {
118
- const { meta, eventsGzB64, console: consoleRows, network, markdown, pruned } = data;
947
+ const { meta, eventsGzB64, console: consoleRows, network, security, markdown, pruned, eventCount, firstTs, lastTs, footer = true, } = data;
119
948
  const title = `tracelane — ${meta.spec ?? '(no spec)'} :: ${meta.title} (${meta.status})`;
949
+ // Banner only when the events were pruned to fit the size cap (ADR-0005).
120
950
  const banner = pruned
121
951
  ? '<div class="banner">Some recorded events were pruned to fit the 25 MB report budget — replay may skip detail.</div>'
122
952
  : '';
953
+ const consoleCount = consoleRows.length;
954
+ const networkCount = network.length;
955
+ const securityCount = security.length;
956
+ // Advisory security tab + pane — rendered ONLY when there are findings
957
+ // (no zero-state; the analyzer is advisory). Mirrors the Console/Network
958
+ // panel markup but without time-sync (findings have no replay moment).
959
+ const securityTab = securityCount > 0
960
+ ? `<button class="tab" type="button" role="tab" id="tab-security" aria-selected="false" aria-controls="pane-security" data-pane="pane-security">
961
+ Security <span class="count-total">${securityCount}</span>
962
+ </button>`
963
+ : '';
964
+ const securityPane = securityCount > 0
965
+ ? `<div class="panel-pane" id="pane-security" role="tabpanel" aria-labelledby="tab-security">
966
+ <div class="panel-toolbar panel-toolbar-sec">
967
+ <span class="panel-subtitle">Observed during the test run — advisory hygiene signals, not a security audit.</span>
968
+ <input type="text" class="panel-filter" placeholder="Filter findings…" aria-label="Filter security findings" />
969
+ </div>
970
+ <div id="security-rows" class="panel-content"></div>
971
+ </div>`
972
+ : '';
123
973
  // 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
974
  const dataScript = `const META = ${serializeForScript(meta)};\n` +
127
975
  `const EVENTS_GZ_B64 = "${eventsGzB64}";\n` +
128
976
  `const CONSOLE = ${serializeForScript(consoleRows)};\n` +
129
977
  `const NETWORK = ${serializeForScript(network)};\n` +
130
- `const MARKDOWN = ${serializeForScript(markdown)};`;
978
+ `const SECURITY = ${serializeForScript(security)};\n` +
979
+ `const MARKDOWN = ${serializeForScript(markdown)};\n` +
980
+ `const FIRST_TS = ${firstTs};\n` +
981
+ `const LAST_TS = ${lastTs};`;
982
+ const sessionRangeText = firstTs && lastTs && lastTs > firstTs
983
+ ? `+0:00 → +${formatRelativeMs(lastTs - firstTs)} · ${eventCount.toLocaleString('en-US')} events`
984
+ : `${eventCount.toLocaleString('en-US')} events`;
131
985
  return `<!doctype html>
132
986
  <html lang="en">
133
987
  <head>
@@ -135,23 +989,84 @@ export function renderReportHtml(data) {
135
989
  <meta name="viewport" content="width=device-width, initial-scale=1">
136
990
  <title>${escapeHtml(title)}</title>
137
991
  <style>${loadPlayerCss()}</style>
138
- <style>${SHELL_CSS}</style>
992
+ <style>${buildShellCss()}</style>
139
993
  </head>
140
994
  <body>
141
- ${renderMetaHeader(meta)}
995
+ ${renderHero(meta, eventCount)}
142
996
  ${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>
997
+ <main class="investigation">
998
+ <section class="replay" aria-label="Session replay">
999
+ <div class="replay-header">
1000
+ <h2>Replay</h2>
1001
+ <span class="timestamp">${escapeHtml(sessionRangeText)}</span>
1002
+ </div>
1003
+ <div id="player" role="img" aria-label="rrweb player"></div>
1004
+ </section>
1005
+ <aside class="panels" aria-label="Investigation panels">
1006
+ <div class="tabs" role="tablist">
1007
+ <button class="tab active" type="button" role="tab" id="tab-console" aria-selected="true" aria-controls="pane-console" data-pane="pane-console">
1008
+ Console <span class="count">0</span><span class="count-total">/ ${consoleCount}</span>
1009
+ </button>
1010
+ <button class="tab" type="button" role="tab" id="tab-network" aria-selected="false" aria-controls="pane-network" data-pane="pane-network">
1011
+ Network <span class="count">0</span><span class="count-total">/ ${networkCount}</span>
1012
+ </button>
1013
+ ${securityTab}
1014
+ <button class="tab" type="button" role="tab" id="tab-actions" aria-selected="false" aria-controls="pane-actions" data-pane="pane-actions">
1015
+ Actions <span class="soon-pill">soon</span>
1016
+ </button>
1017
+ <button class="tab" type="button" role="tab" id="tab-timeline" aria-selected="false" aria-controls="pane-timeline" data-pane="pane-timeline">
1018
+ Timeline <span class="soon-pill">soon</span>
1019
+ </button>
1020
+ </div>
1021
+
1022
+ <div class="panel-pane active" id="pane-console" role="tabpanel" aria-labelledby="tab-console">
1023
+ <div class="panel-toolbar">
1024
+ <input type="text" class="panel-filter" placeholder="Filter console…" aria-label="Filter console messages" />
1025
+ <button class="filter-chip" type="button" data-level="error">errors</button>
1026
+ <button class="filter-chip" type="button" data-level="warn">warn</button>
1027
+ </div>
1028
+ <div id="console-rows" class="panel-content"></div>
1029
+ <div class="panel-pending" id="console-pending" role="status" aria-live="polite">Console output will appear during playback.</div>
1030
+ </div>
1031
+
1032
+ <div class="panel-pane" id="pane-network" role="tabpanel" aria-labelledby="tab-network">
1033
+ <div class="panel-toolbar">
1034
+ <input type="text" class="panel-filter" placeholder="Filter URLs…" aria-label="Filter network requests" />
1035
+ </div>
1036
+ <div id="network-rows" class="panel-content"></div>
1037
+ <div class="panel-pending" id="network-pending" role="status" aria-live="polite">Network errors will appear during playback.</div>
1038
+ </div>
1039
+
1040
+ ${securityPane}
1041
+
1042
+ <div class="panel-pane" id="pane-actions" role="tabpanel" aria-labelledby="tab-actions">
1043
+ <div class="coming-soon">
1044
+ <strong>Actions panel — coming soon</strong>
1045
+ <span>User-input event extraction lands in a follow-up changeset.</span>
1046
+ <span>Use the rrweb-player scrubber to walk through actions manually.</span>
1047
+ </div>
1048
+ </div>
1049
+
1050
+ <div class="panel-pane" id="pane-timeline" role="tabpanel" aria-labelledby="tab-timeline">
1051
+ <div class="coming-soon">
1052
+ <strong>Timeline panel — coming soon</strong>
1053
+ <span>Use the rrweb-player scrubber above to navigate the recording today.</span>
1054
+ <span>A richer event-by-event timeline lands in a follow-up changeset.</span>
1055
+ </div>
1056
+ </div>
1057
+ </aside>
150
1058
  </main>
1059
+
1060
+ <button class="fab" id="copy-md" type="button" aria-label="Copy report as Markdown for AI">
1061
+ <span class="icon" aria-hidden="true"></span>
1062
+ <span class="label">Copy as Markdown for AI</span>
1063
+ </button>
1064
+
151
1065
  <script>${loadFflateGunzipSource()}</script>
152
1066
  <script>${loadPlayerUmd()}</script>
153
1067
  <script>${dataScript}</script>
154
1068
  <script>${BOOTSTRAP}</script>
1069
+ ${footer ? FOOTER_HTML : ''}
155
1070
  </body>
156
1071
  </html>`;
157
1072
  }