@tracelane/report 0.1.0-alpha.1 → 0.1.0-alpha.11
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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +4 -4
- package/dist/assets.d.ts +20 -0
- package/dist/assets.d.ts.map +1 -1
- package/dist/assets.js +60 -6
- package/dist/assets.js.map +1 -1
- package/dist/build-report.d.ts.map +1 -1
- package/dist/build-report.js +10 -0
- package/dist/build-report.js.map +1 -1
- package/dist/metadata.d.ts +21 -2
- package/dist/metadata.d.ts.map +1 -1
- package/dist/metadata.js +94 -27
- package/dist/metadata.js.map +1 -1
- package/dist/panels.d.ts +13 -3
- package/dist/panels.d.ts.map +1 -1
- package/dist/panels.js +76 -11
- package/dist/panels.js.map +1 -1
- package/dist/template.d.ts +7 -0
- package/dist/template.d.ts.map +1 -1
- package/dist/template.js +878 -72
- package/dist/template.js.map +1 -1
- package/package.json +31 -13
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
|
|
6
|
-
// panel containers,
|
|
7
|
-
// (player UMD/CSS, fflate UMD
|
|
8
|
-
|
|
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 {
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
*
|
|
48
|
-
*
|
|
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
|
|
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
|
-
|
|
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.__tracelanePlayer = rrPlayer; } catch (_) {}
|
|
67
584
|
} else {
|
|
68
|
-
|
|
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,236 @@ const BOOTSTRAP = `
|
|
|
75
598
|
return n;
|
|
76
599
|
}
|
|
77
600
|
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
83
|
-
row
|
|
84
|
-
|
|
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) {
|
|
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
|
-
|
|
95
|
-
row.
|
|
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
|
+
// TODO(perf): re-queries DOM and re-parses data-time on every tick (~30 Hz).
|
|
666
|
+
// Fine at current demo scale (~10 rows). When Actions / Timeline panels ship
|
|
667
|
+
// and reports start carrying hundreds of entries, cache [rows, times] once
|
|
668
|
+
// after render and skip work when t === lastT.
|
|
669
|
+
function tickPanels(currentMs) {
|
|
670
|
+
var t = Math.max(0, Math.floor(currentMs || 0));
|
|
671
|
+
var names = ['console', 'network'];
|
|
672
|
+
for (var n = 0; n < names.length; n++) {
|
|
673
|
+
var name = names[n];
|
|
674
|
+
var container = document.getElementById(name + '-rows');
|
|
675
|
+
var pending = document.getElementById(name + '-pending');
|
|
676
|
+
var badgeCurrent = document.querySelector(
|
|
677
|
+
'.tab[data-pane="pane-' + name + '"] .count'
|
|
678
|
+
);
|
|
679
|
+
if (!container) continue;
|
|
680
|
+
var rows = container.querySelectorAll('.row');
|
|
681
|
+
// Detect "was at bottom" BEFORE flipping classes so auto-scroll only
|
|
682
|
+
// triggers when the user hasn't scrolled up to inspect older entries.
|
|
683
|
+
var wasAtBottom =
|
|
684
|
+
container.scrollHeight - container.scrollTop - container.clientHeight <= 20;
|
|
685
|
+
var visibleCount = 0;
|
|
686
|
+
var hasAnyRows = rows.length > 0;
|
|
687
|
+
for (var i = 0; i < rows.length; i++) {
|
|
688
|
+
var rowTime = parseInt(rows[i].getAttribute('data-time') || '0', 10);
|
|
689
|
+
var isFuture = rowTime > t;
|
|
690
|
+
rows[i].classList.toggle('is-future', isFuture);
|
|
691
|
+
if (!isFuture && !rows[i].classList.contains('hidden')) visibleCount++;
|
|
692
|
+
}
|
|
693
|
+
if (badgeCurrent) badgeCurrent.textContent = String(visibleCount);
|
|
694
|
+
if (pending) {
|
|
695
|
+
// Pending placeholder shows when rows exist but none visible yet.
|
|
696
|
+
pending.classList.toggle('is-hidden', !hasAnyRows || visibleCount > 0);
|
|
697
|
+
}
|
|
698
|
+
if (wasAtBottom && visibleCount > 0) {
|
|
699
|
+
container.scrollTop = container.scrollHeight;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (rrPlayer && typeof rrPlayer.addEventListener === 'function') {
|
|
705
|
+
rrPlayer.addEventListener('ui-update-current-time', function (e) {
|
|
706
|
+
// rrweb-player's CustomEvent shape: { payload: <ms> } in alpha.4. If
|
|
707
|
+
// the bundled version ever changes shape, fall back to reading the
|
|
708
|
+
// replayer directly.
|
|
709
|
+
var payload = e && e.payload;
|
|
710
|
+
if (typeof payload !== 'number') {
|
|
711
|
+
try { payload = rrPlayer.getReplayer().getCurrentTime(); } catch (_) { payload = 0; }
|
|
712
|
+
}
|
|
713
|
+
tickPanels(payload);
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
tickPanels(0);
|
|
717
|
+
|
|
718
|
+
// ---- Click-to-seek: clicking a row jumps the player to that moment -----
|
|
719
|
+
if (rrPlayer && typeof rrPlayer.goto === 'function') {
|
|
720
|
+
var seekContainers = ['console-rows', 'network-rows'];
|
|
721
|
+
for (var sc = 0; sc < seekContainers.length; sc++) {
|
|
722
|
+
(function (containerId) {
|
|
723
|
+
var ctr = document.getElementById(containerId);
|
|
724
|
+
if (!ctr) return;
|
|
725
|
+
ctr.addEventListener('click', function (ev) {
|
|
726
|
+
var target = ev.target;
|
|
727
|
+
// Walk up to the .row ancestor (clicks may land on inner spans).
|
|
728
|
+
while (target && target !== ctr && !target.classList.contains('row')) {
|
|
729
|
+
target = target.parentNode;
|
|
730
|
+
}
|
|
731
|
+
if (!target || target === ctr) return;
|
|
732
|
+
var t = parseInt(target.getAttribute('data-time') || '0', 10);
|
|
733
|
+
if (isFinite(t) && t >= 0) {
|
|
734
|
+
rrPlayer.goto(t, false);
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
})(seekContainers[sc]);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ---- Tab switching -----------------------------------------------------
|
|
742
|
+
var tabs = document.querySelectorAll('.tab');
|
|
743
|
+
var panes = document.querySelectorAll('.panel-pane');
|
|
744
|
+
for (var t = 0; t < tabs.length; t++) {
|
|
745
|
+
(function (tab) {
|
|
746
|
+
tab.addEventListener('click', function () {
|
|
747
|
+
var targetId = tab.getAttribute('data-pane');
|
|
748
|
+
for (var i = 0; i < tabs.length; i++) tabs[i].classList.remove('active');
|
|
749
|
+
for (var j = 0; j < panes.length; j++) {
|
|
750
|
+
panes[j].classList.toggle('active', panes[j].id === targetId);
|
|
751
|
+
}
|
|
752
|
+
tab.classList.add('active');
|
|
753
|
+
});
|
|
754
|
+
})(tabs[t]);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ---- Per-pane filter input + level chips ------------------------------
|
|
758
|
+
function wireFilters(paneEl) {
|
|
759
|
+
var input = paneEl.querySelector('.panel-filter');
|
|
760
|
+
var chips = paneEl.querySelectorAll('.filter-chip');
|
|
761
|
+
var rows = paneEl.querySelectorAll('.row');
|
|
762
|
+
|
|
763
|
+
var activeLevels = {};
|
|
764
|
+
for (var c = 0; c < chips.length; c++) {
|
|
765
|
+
if (chips[c].classList.contains('active')) {
|
|
766
|
+
activeLevels[chips[c].getAttribute('data-level') || ''] = true;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function applyFilter() {
|
|
771
|
+
var q = (input ? input.value : '').trim().toLowerCase();
|
|
772
|
+
var anyActive = false;
|
|
773
|
+
for (var k in activeLevels) { if (activeLevels[k]) { anyActive = true; break; } }
|
|
774
|
+
for (var i = 0; i < rows.length; i++) {
|
|
775
|
+
var row = rows[i];
|
|
776
|
+
var text = row.getAttribute('data-text') || '';
|
|
777
|
+
var level = row.getAttribute('data-level') || '';
|
|
778
|
+
// Level filter: if any chips are active, row must match one of them.
|
|
779
|
+
// Network rows have no level — treated as "always shown" by level filter.
|
|
780
|
+
var levelOk = !anyActive || !level || activeLevels[level];
|
|
781
|
+
var textOk = !q || text.indexOf(q) !== -1;
|
|
782
|
+
row.classList.toggle('hidden', !(levelOk && textOk));
|
|
783
|
+
}
|
|
784
|
+
// Re-tick so the badge reflects (filter ∩ time-sync) visibility. Use
|
|
785
|
+
// the player's current time when available so we don't reset to t=0.
|
|
786
|
+
var nowMs = 0;
|
|
787
|
+
try { if (rrPlayer) nowMs = rrPlayer.getReplayer().getCurrentTime(); } catch (_) {}
|
|
788
|
+
tickPanels(nowMs);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (input) input.addEventListener('input', applyFilter);
|
|
792
|
+
for (var c2 = 0; c2 < chips.length; c2++) {
|
|
793
|
+
(function (chip) {
|
|
794
|
+
chip.addEventListener('click', function () {
|
|
795
|
+
var lvl = chip.getAttribute('data-level') || '';
|
|
796
|
+
if (chip.classList.contains('active')) {
|
|
797
|
+
chip.classList.remove('active');
|
|
798
|
+
activeLevels[lvl] = false;
|
|
799
|
+
} else {
|
|
800
|
+
chip.classList.add('active');
|
|
801
|
+
activeLevels[lvl] = true;
|
|
802
|
+
}
|
|
803
|
+
applyFilter();
|
|
804
|
+
});
|
|
805
|
+
})(chips[c2]);
|
|
806
|
+
}
|
|
807
|
+
applyFilter();
|
|
808
|
+
}
|
|
102
809
|
|
|
103
|
-
var
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
810
|
+
for (var p = 0; p < panes.length; p++) wireFilters(panes[p]);
|
|
811
|
+
|
|
812
|
+
// ---- Copy-as-Markdown FAB ----------------------------------------------
|
|
813
|
+
var fab = document.getElementById('copy-md');
|
|
814
|
+
if (fab) {
|
|
815
|
+
var fabLabel = fab.querySelector('.label');
|
|
816
|
+
var resetTimer = null;
|
|
817
|
+
fab.addEventListener('click', function () {
|
|
818
|
+
var done = function () {
|
|
819
|
+
fab.classList.add('copied');
|
|
820
|
+
if (fabLabel) fabLabel.textContent = 'Copied to clipboard';
|
|
821
|
+
if (resetTimer) clearTimeout(resetTimer);
|
|
822
|
+
resetTimer = setTimeout(function () {
|
|
823
|
+
fab.classList.remove('copied');
|
|
824
|
+
if (fabLabel) fabLabel.textContent = 'Copy as Markdown for AI';
|
|
825
|
+
}, 2000);
|
|
826
|
+
};
|
|
107
827
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
108
|
-
navigator.clipboard.writeText(MARKDOWN).then(done, function () {
|
|
828
|
+
navigator.clipboard.writeText(MARKDOWN).then(done, function () {
|
|
829
|
+
window.prompt('Copy the Markdown below:', MARKDOWN);
|
|
830
|
+
});
|
|
109
831
|
} else {
|
|
110
832
|
window.prompt('Copy the Markdown below:', MARKDOWN);
|
|
111
833
|
}
|
|
@@ -113,21 +835,47 @@ const BOOTSTRAP = `
|
|
|
113
835
|
}
|
|
114
836
|
})();
|
|
115
837
|
`;
|
|
838
|
+
/**
|
|
839
|
+
* Self-marketing footer (Phase 5 indirect-virality artifact).
|
|
840
|
+
*
|
|
841
|
+
* Every report shared in a PR comment or attached to a JIRA ticket becomes a
|
|
842
|
+
* tracked acquisition channel — the Loom / Calendly / Statuspage compounding
|
|
843
|
+
* pattern. UTM params let us attribute click-through downstream; the link
|
|
844
|
+
* targets the repo's `packages/tracelane-wdio` directory because the install
|
|
845
|
+
* command (`npm i @tracelane/wdio`) is what we want a reader to see first
|
|
846
|
+
* (per the research's "link to the install command, not the marketing site"
|
|
847
|
+
* rule).
|
|
848
|
+
*/
|
|
849
|
+
const FOOTER_HTML = '<footer class="attrib">' +
|
|
850
|
+
' <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.' +
|
|
851
|
+
'</footer>';
|
|
852
|
+
function formatRelativeMs(ms) {
|
|
853
|
+
const s = ms / 1000;
|
|
854
|
+
const m = Math.floor(s / 60);
|
|
855
|
+
const r = Math.floor(s - m * 60);
|
|
856
|
+
return `${m}:${r < 10 ? '0' : ''}${r}`;
|
|
857
|
+
}
|
|
116
858
|
/** Compose the full self-contained HTML document. */
|
|
117
859
|
export function renderReportHtml(data) {
|
|
118
|
-
const { meta, eventsGzB64, console: consoleRows, network, markdown, pruned } = data;
|
|
860
|
+
const { meta, eventsGzB64, console: consoleRows, network, markdown, pruned, eventCount, firstTs, lastTs, } = data;
|
|
119
861
|
const title = `tracelane — ${meta.spec ?? '(no spec)'} :: ${meta.title} (${meta.status})`;
|
|
862
|
+
// Banner only when the events were pruned to fit the size cap (ADR-0005).
|
|
120
863
|
const banner = pruned
|
|
121
864
|
? '<div class="banner">Some recorded events were pruned to fit the 25 MB report budget — replay may skip detail.</div>'
|
|
122
865
|
: '';
|
|
866
|
+
const consoleCount = consoleRows.length;
|
|
867
|
+
const networkCount = network.length;
|
|
123
868
|
// 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
869
|
const dataScript = `const META = ${serializeForScript(meta)};\n` +
|
|
127
870
|
`const EVENTS_GZ_B64 = "${eventsGzB64}";\n` +
|
|
128
871
|
`const CONSOLE = ${serializeForScript(consoleRows)};\n` +
|
|
129
872
|
`const NETWORK = ${serializeForScript(network)};\n` +
|
|
130
|
-
`const MARKDOWN = ${serializeForScript(markdown)}
|
|
873
|
+
`const MARKDOWN = ${serializeForScript(markdown)};\n` +
|
|
874
|
+
`const FIRST_TS = ${firstTs};\n` +
|
|
875
|
+
`const LAST_TS = ${lastTs};`;
|
|
876
|
+
const sessionRangeText = firstTs && lastTs && lastTs > firstTs
|
|
877
|
+
? `+0:00 → +${formatRelativeMs(lastTs - firstTs)} · ${eventCount.toLocaleString('en-US')} events`
|
|
878
|
+
: `${eventCount.toLocaleString('en-US')} events`;
|
|
131
879
|
return `<!doctype html>
|
|
132
880
|
<html lang="en">
|
|
133
881
|
<head>
|
|
@@ -135,23 +883,81 @@ export function renderReportHtml(data) {
|
|
|
135
883
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
136
884
|
<title>${escapeHtml(title)}</title>
|
|
137
885
|
<style>${loadPlayerCss()}</style>
|
|
138
|
-
<style>${
|
|
886
|
+
<style>${buildShellCss()}</style>
|
|
139
887
|
</head>
|
|
140
888
|
<body>
|
|
141
|
-
${
|
|
889
|
+
${renderHero(meta, eventCount)}
|
|
142
890
|
${banner}
|
|
143
|
-
<
|
|
144
|
-
<
|
|
145
|
-
<
|
|
146
|
-
<
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
891
|
+
<main class="investigation">
|
|
892
|
+
<section class="replay" aria-label="Session replay">
|
|
893
|
+
<div class="replay-header">
|
|
894
|
+
<h2>Replay</h2>
|
|
895
|
+
<span class="timestamp">${escapeHtml(sessionRangeText)}</span>
|
|
896
|
+
</div>
|
|
897
|
+
<div id="player" role="img" aria-label="rrweb player"></div>
|
|
898
|
+
</section>
|
|
899
|
+
<aside class="panels" aria-label="Investigation panels">
|
|
900
|
+
<div class="tabs" role="tablist">
|
|
901
|
+
<button class="tab active" type="button" role="tab" data-pane="pane-console">
|
|
902
|
+
Console <span class="count">0</span><span class="count-total">/ ${consoleCount}</span>
|
|
903
|
+
</button>
|
|
904
|
+
<button class="tab" type="button" role="tab" data-pane="pane-network">
|
|
905
|
+
Network <span class="count">0</span><span class="count-total">/ ${networkCount}</span>
|
|
906
|
+
</button>
|
|
907
|
+
<button class="tab" type="button" role="tab" data-pane="pane-actions">
|
|
908
|
+
Actions
|
|
909
|
+
</button>
|
|
910
|
+
<button class="tab" type="button" role="tab" data-pane="pane-timeline">
|
|
911
|
+
Timeline
|
|
912
|
+
</button>
|
|
913
|
+
</div>
|
|
914
|
+
|
|
915
|
+
<div class="panel-pane active" id="pane-console" role="tabpanel">
|
|
916
|
+
<div class="panel-toolbar">
|
|
917
|
+
<input type="text" class="panel-filter" placeholder="Filter console…" aria-label="Filter console messages" />
|
|
918
|
+
<button class="filter-chip" type="button" data-level="error">errors</button>
|
|
919
|
+
<button class="filter-chip" type="button" data-level="warn">warn</button>
|
|
920
|
+
</div>
|
|
921
|
+
<div id="console-rows" class="panel-content"></div>
|
|
922
|
+
<div class="panel-pending" id="console-pending" role="status" aria-live="polite">Console output will appear during playback.</div>
|
|
923
|
+
</div>
|
|
924
|
+
|
|
925
|
+
<div class="panel-pane" id="pane-network" role="tabpanel">
|
|
926
|
+
<div class="panel-toolbar">
|
|
927
|
+
<input type="text" class="panel-filter" placeholder="Filter URLs…" aria-label="Filter network requests" />
|
|
928
|
+
</div>
|
|
929
|
+
<div id="network-rows" class="panel-content"></div>
|
|
930
|
+
<div class="panel-pending" id="network-pending" role="status" aria-live="polite">Network errors will appear during playback.</div>
|
|
931
|
+
</div>
|
|
932
|
+
|
|
933
|
+
<div class="panel-pane" id="pane-actions" role="tabpanel">
|
|
934
|
+
<div class="coming-soon">
|
|
935
|
+
<strong>Actions panel — coming soon</strong>
|
|
936
|
+
<span>User-input event extraction lands in a follow-up changeset.</span>
|
|
937
|
+
<span>Use the rrweb-player scrubber to walk through actions manually.</span>
|
|
938
|
+
</div>
|
|
939
|
+
</div>
|
|
940
|
+
|
|
941
|
+
<div class="panel-pane" id="pane-timeline" role="tabpanel">
|
|
942
|
+
<div class="coming-soon">
|
|
943
|
+
<strong>Timeline panel — coming soon</strong>
|
|
944
|
+
<span>Use the rrweb-player scrubber above to navigate the recording today.</span>
|
|
945
|
+
<span>A richer event-by-event timeline lands in a follow-up changeset.</span>
|
|
946
|
+
</div>
|
|
947
|
+
</div>
|
|
948
|
+
</aside>
|
|
150
949
|
</main>
|
|
950
|
+
|
|
951
|
+
<button class="fab" id="copy-md" type="button" aria-label="Copy report as Markdown for AI">
|
|
952
|
+
<span class="icon" aria-hidden="true"></span>
|
|
953
|
+
<span class="label">Copy as Markdown for AI</span>
|
|
954
|
+
</button>
|
|
955
|
+
|
|
151
956
|
<script>${loadFflateGunzipSource()}</script>
|
|
152
957
|
<script>${loadPlayerUmd()}</script>
|
|
153
958
|
<script>${dataScript}</script>
|
|
154
959
|
<script>${BOOTSTRAP}</script>
|
|
960
|
+
${FOOTER_HTML}
|
|
155
961
|
</body>
|
|
156
962
|
</html>`;
|
|
157
963
|
}
|