playwright-toolbox 1.0.0

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 (44) hide show
  1. package/.changeset/README.md +9 -0
  2. package/.changeset/config.json +11 -0
  3. package/README.md +90 -0
  4. package/package.json +26 -0
  5. package/packages/playwright-config/CHANGELOG.md +21 -0
  6. package/packages/playwright-config/README.md +22 -0
  7. package/packages/playwright-config/package.json +47 -0
  8. package/packages/playwright-config/src/index.ts +21 -0
  9. package/packages/playwright-config/tsconfig.json +19 -0
  10. package/packages/playwright-history-dashboard/CHANGELOG.md +21 -0
  11. package/packages/playwright-history-dashboard/README.md +216 -0
  12. package/packages/playwright-history-dashboard/RELEASING.md +249 -0
  13. package/packages/playwright-history-dashboard/dashboard/index.html +2825 -0
  14. package/packages/playwright-history-dashboard/package-lock.json +105 -0
  15. package/packages/playwright-history-dashboard/package.json +56 -0
  16. package/packages/playwright-history-dashboard/pw-dashboard.config.js +22 -0
  17. package/packages/playwright-history-dashboard/scripts/init.ts +95 -0
  18. package/packages/playwright-history-dashboard/src/reporter.ts +376 -0
  19. package/packages/playwright-history-dashboard/tsconfig.json +19 -0
  20. package/packages/pw-standard/.eslintrc.js +23 -0
  21. package/packages/pw-standard/CHANGELOG.md +31 -0
  22. package/packages/pw-standard/README.md +50 -0
  23. package/packages/pw-standard/jest.config.js +28 -0
  24. package/packages/pw-standard/package.json +86 -0
  25. package/packages/pw-standard/src/base/index.ts +19 -0
  26. package/packages/pw-standard/src/eslint/index.ts +91 -0
  27. package/packages/pw-standard/src/eslint/rules/no-brittle-selectors.ts +53 -0
  28. package/packages/pw-standard/src/eslint/rules/no-focused-tests.ts +61 -0
  29. package/packages/pw-standard/src/eslint/rules/no-page-pause.ts +37 -0
  30. package/packages/pw-standard/src/eslint/rules/no-wait-for-timeout.ts +34 -0
  31. package/packages/pw-standard/src/eslint/rules/prefer-web-first-assertions.ts +90 -0
  32. package/packages/pw-standard/src/eslint/rules/require-test-description.ts +159 -0
  33. package/packages/pw-standard/src/eslint/types.ts +20 -0
  34. package/packages/pw-standard/src/eslint/utils/ast.ts +59 -0
  35. package/packages/pw-standard/src/index.ts +13 -0
  36. package/packages/pw-standard/src/playwright/index.ts +6 -0
  37. package/packages/pw-standard/src/tsconfig/base.json +21 -0
  38. package/packages/pw-standard/src/tsconfig/strict.json +11 -0
  39. package/packages/pw-standard/tests/eslint/no-brittle-selectors.test.ts +34 -0
  40. package/packages/pw-standard/tests/eslint/no-page-pause-and-focused.test.ts +41 -0
  41. package/packages/pw-standard/tests/eslint/no-wait-for-timeout.test.ts +30 -0
  42. package/packages/pw-standard/tests/eslint/prefer-web-first-assertions.test.ts +25 -0
  43. package/packages/pw-standard/tests/eslint/require-test-description.test.ts +49 -0
  44. package/packages/pw-standard/tsconfig.json +24 -0
@@ -0,0 +1,2825 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Test History Dashboard</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <style>
10
+ :root {
11
+ --bg: #0f1117;
12
+ --surface: #181c27;
13
+ --surface2: #1f2333;
14
+ --border: #2a2f42;
15
+ --border2: #363c54;
16
+ --text: #e2e4ec;
17
+ --muted: #7b82a0;
18
+ --accent: #5b7cf6;
19
+ --accent2: #7c9fff;
20
+
21
+ --pass: #2dd4a0;
22
+ --pass-bg: rgba(45,212,160,.12);
23
+ --fail: #f25f5c;
24
+ --fail-bg: rgba(242,95,92,.12);
25
+ --flaky: #f5a623;
26
+ --flaky-bg: rgba(245,166,35,.12);
27
+ --skip: #7b82a0;
28
+ --skip-bg: rgba(123,130,160,.12);
29
+
30
+ --radius: 8px;
31
+ --radius-lg: 12px;
32
+ --mono: 'JetBrains Mono', monospace;
33
+ --sans: 'DM Sans', sans-serif;
34
+ }
35
+
36
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
37
+
38
+ body {
39
+ font-family: var(--sans);
40
+ background: var(--bg);
41
+ color: var(--text);
42
+ min-height: 100vh;
43
+ font-size: 14px;
44
+ line-height: 1.5;
45
+ }
46
+
47
+ /* ── Layout ─────────────────────────────────────────────── */
48
+ .shell {
49
+ display: grid;
50
+ grid-template-rows: 56px 1fr auto;
51
+ grid-template-columns: 220px 1fr;
52
+ grid-template-areas:
53
+ "topbar topbar"
54
+ "sidebar main"
55
+ "sidebar footer";
56
+ min-height: 100vh;
57
+ }
58
+
59
+ .topbar {
60
+ grid-area: topbar;
61
+ border-bottom: 1px solid var(--border);
62
+ padding: 0 24px;
63
+ display: flex;
64
+ align-items: center;
65
+ justify-content: space-between;
66
+ height: 56px;
67
+ background: var(--surface);
68
+ position: sticky;
69
+ top: 0;
70
+ z-index: 10;
71
+ }
72
+
73
+ .topbar-brand {
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 10px;
77
+ font-family: var(--mono);
78
+ font-weight: 600;
79
+ font-size: 15px;
80
+ letter-spacing: -0.3px;
81
+ }
82
+
83
+ .topbar-brand svg { flex-shrink: 0; }
84
+
85
+ .topbar-meta {
86
+ font-family: var(--mono);
87
+ font-size: 11px;
88
+ color: var(--muted);
89
+ }
90
+
91
+ /* ── Sidebar ────────────────────────────────────────────── */
92
+ .sidebar {
93
+ grid-area: sidebar;
94
+ background: var(--surface);
95
+ border-right: 1px solid var(--border);
96
+ display: flex;
97
+ flex-direction: column;
98
+ padding: 20px 0;
99
+ position: sticky;
100
+ top: 56px;
101
+ height: calc(100vh - 56px);
102
+ overflow-y: auto;
103
+ }
104
+
105
+ .sidebar-section-label {
106
+ font-family: var(--mono);
107
+ font-size: 10px;
108
+ font-weight: 600;
109
+ text-transform: uppercase;
110
+ letter-spacing: .1em;
111
+ color: var(--border2);
112
+ padding: 0 18px;
113
+ margin-bottom: 6px;
114
+ margin-top: 20px;
115
+ }
116
+
117
+ .sidebar-section-label:first-child { margin-top: 0; }
118
+
119
+ .sidebar-item {
120
+ display: flex;
121
+ align-items: center;
122
+ gap: 10px;
123
+ padding: 9px 18px;
124
+ font-size: 13px;
125
+ font-weight: 500;
126
+ color: var(--muted);
127
+ cursor: pointer;
128
+ border-left: 3px solid transparent;
129
+ transition: color .15s, background .15s, border-color .15s;
130
+ text-decoration: none;
131
+ user-select: none;
132
+ }
133
+
134
+ .sidebar-item:hover {
135
+ color: var(--text);
136
+ background: var(--surface2);
137
+ }
138
+
139
+ .sidebar-item.active {
140
+ color: var(--text);
141
+ background: rgba(91,124,246,.08);
142
+ border-left-color: var(--accent);
143
+ }
144
+
145
+ .sidebar-item svg {
146
+ flex-shrink: 0;
147
+ opacity: .7;
148
+ }
149
+
150
+ .sidebar-item.active svg { opacity: 1; }
151
+
152
+ .sidebar-divider {
153
+ height: 1px;
154
+ background: var(--border);
155
+ margin: 12px 18px;
156
+ }
157
+
158
+ /* Sidebar search input */
159
+ .sidebar-search {
160
+ margin: 4px 14px 8px;
161
+ position: relative;
162
+ }
163
+
164
+ .sidebar-search input {
165
+ width: 100%;
166
+ padding: 7px 10px 7px 30px;
167
+ background: var(--surface2);
168
+ border: 1px solid var(--border);
169
+ border-radius: var(--radius);
170
+ color: var(--text);
171
+ font-family: var(--sans);
172
+ font-size: 12px;
173
+ transition: border-color .15s;
174
+ }
175
+
176
+ .sidebar-search input:focus {
177
+ outline: none;
178
+ border-color: var(--accent);
179
+ }
180
+
181
+ .sidebar-search input::placeholder { color: var(--muted); }
182
+
183
+ .sidebar-search-icon {
184
+ position: absolute;
185
+ left: 9px;
186
+ top: 50%;
187
+ transform: translateY(-50%);
188
+ color: var(--muted);
189
+ pointer-events: none;
190
+ font-size: 12px;
191
+ }
192
+
193
+ /* Sidebar run list (mini) */
194
+ .sidebar-run {
195
+ display: flex;
196
+ align-items: center;
197
+ gap: 8px;
198
+ padding: 7px 18px;
199
+ font-size: 11px;
200
+ font-family: var(--mono);
201
+ color: var(--muted);
202
+ cursor: pointer;
203
+ transition: background .15s, color .15s;
204
+ border-left: 3px solid transparent;
205
+ }
206
+
207
+ .sidebar-run:hover { background: var(--surface2); color: var(--text); }
208
+
209
+ .sidebar-run.active {
210
+ background: rgba(91,124,246,.08);
211
+ border-left-color: var(--accent);
212
+ color: var(--text);
213
+ }
214
+
215
+ .sidebar-run-dot {
216
+ width: 6px;
217
+ height: 6px;
218
+ border-radius: 50%;
219
+ flex-shrink: 0;
220
+ }
221
+
222
+ .sidebar-run-info { flex: 1; min-width: 0; }
223
+ .sidebar-run-date { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
224
+ .sidebar-run-stat { font-size: 10px; color: var(--muted); margin-top: 1px; }
225
+
226
+ .main {
227
+ grid-area: main;
228
+ padding: 24px 28px 48px;
229
+ min-width: 0;
230
+ }
231
+
232
+ /* ── Latest run stat cards ──────────────────────────────── */
233
+ .latest-run-section {
234
+ margin-bottom: 28px;
235
+ }
236
+
237
+ .latest-run-header {
238
+ display: flex;
239
+ align-items: center;
240
+ justify-content: space-between;
241
+ margin-bottom: 12px;
242
+ }
243
+
244
+ .latest-run-title {
245
+ font-family: var(--mono);
246
+ font-size: 13px;
247
+ font-weight: 600;
248
+ color: var(--text);
249
+ }
250
+
251
+ .latest-run-ts {
252
+ font-family: var(--mono);
253
+ font-size: 11px;
254
+ color: var(--muted);
255
+ }
256
+
257
+ .latest-stats-row {
258
+ display: grid;
259
+ grid-template-columns: repeat(7, 1fr);
260
+ gap: 10px;
261
+ }
262
+
263
+ .latest-stat {
264
+ background: var(--surface);
265
+ border: 1px solid var(--border);
266
+ border-radius: var(--radius-lg);
267
+ padding: 14px 16px;
268
+ display: flex;
269
+ flex-direction: column;
270
+ gap: 4px;
271
+ border-top: 3px solid transparent;
272
+ }
273
+
274
+ .latest-stat.pass-card { border-top-color: var(--pass); }
275
+ .latest-stat.fail-card { border-top-color: var(--fail); }
276
+ .latest-stat.flaky-card { border-top-color: var(--flaky); }
277
+ .latest-stat.skip-card { border-top-color: var(--skip); }
278
+ .latest-stat.dur-card { border-top-color: var(--accent); }
279
+ .latest-stat.rate-card { border-top-color: var(--accent2); }
280
+ .latest-stat.total-card { border-top-color: var(--border2); }
281
+
282
+ .latest-stat-label {
283
+ font-size: 10px;
284
+ font-family: var(--mono);
285
+ text-transform: uppercase;
286
+ letter-spacing: .08em;
287
+ color: var(--muted);
288
+ }
289
+
290
+ .latest-stat-value {
291
+ font-size: 26px;
292
+ font-weight: 600;
293
+ font-family: var(--mono);
294
+ line-height: 1;
295
+ }
296
+
297
+ .latest-stat.clickable {
298
+ cursor: pointer;
299
+ transition: border-color .15s, background .15s, transform .1s;
300
+ }
301
+
302
+ .latest-stat.clickable:hover {
303
+ background: var(--surface2);
304
+ transform: translateY(-1px);
305
+ }
306
+
307
+ .latest-stat.clickable.active-filter {
308
+ background: var(--surface2);
309
+ box-shadow: 0 0 0 1px var(--border2);
310
+ }
311
+
312
+ .latest-stat.pass-card.active-filter { box-shadow: 0 0 0 1px var(--pass); }
313
+ .latest-stat.fail-card.active-filter { box-shadow: 0 0 0 1px var(--fail); }
314
+ .latest-stat.flaky-card.active-filter { box-shadow: 0 0 0 1px var(--flaky); }
315
+ .latest-stat.skip-card.active-filter { box-shadow: 0 0 0 1px var(--skip); }
316
+
317
+ /* Show all filter banner */
318
+ .filter-banner {
319
+ display: flex;
320
+ align-items: center;
321
+ justify-content: space-between;
322
+ padding: 8px 14px;
323
+ background: var(--surface2);
324
+ border: 1px solid var(--border2);
325
+ border-radius: var(--radius);
326
+ margin-bottom: 10px;
327
+ font-family: var(--mono);
328
+ font-size: 12px;
329
+ color: var(--text);
330
+ }
331
+
332
+ .filter-banner-label { color: var(--muted); }
333
+ .filter-banner-label strong { color: var(--text); }
334
+
335
+ .filter-banner-clear {
336
+ background: none;
337
+ border: 1px solid var(--border2);
338
+ border-radius: 5px;
339
+ color: var(--muted);
340
+ font-family: var(--mono);
341
+ font-size: 11px;
342
+ padding: 3px 10px;
343
+ cursor: pointer;
344
+ transition: color .15s, border-color .15s;
345
+ }
346
+
347
+ .filter-banner-clear:hover { color: var(--text); border-color: var(--text); }
348
+
349
+ /* ── Stats row ──────────────────────────────────────────── */
350
+ .stats-row {
351
+ display: grid;
352
+ grid-template-columns: repeat(4, 1fr);
353
+ gap: 12px;
354
+ margin-bottom: 28px;
355
+ }
356
+
357
+ .stat-card {
358
+ background: var(--surface);
359
+ border: 1px solid var(--border);
360
+ border-radius: var(--radius-lg);
361
+ padding: 20px 22px;
362
+ display: flex;
363
+ flex-direction: column;
364
+ gap: 6px;
365
+ }
366
+
367
+ .stat-card-label {
368
+ font-size: 11px;
369
+ font-family: var(--mono);
370
+ text-transform: uppercase;
371
+ letter-spacing: .08em;
372
+ color: var(--muted);
373
+ }
374
+
375
+ .stat-card-value {
376
+ font-size: 32px;
377
+ font-weight: 600;
378
+ font-family: var(--mono);
379
+ line-height: 1;
380
+ }
381
+
382
+ .stat-card-sub {
383
+ font-size: 12px;
384
+ color: var(--muted);
385
+ }
386
+
387
+ /* ── Section titles ─────────────────────────────────────── */
388
+ .section { margin-bottom: 28px; }
389
+
390
+ .section-header {
391
+ display: flex;
392
+ align-items: center;
393
+ justify-content: space-between;
394
+ margin-bottom: 16px;
395
+ }
396
+
397
+ .section-title {
398
+ font-size: 13px;
399
+ font-family: var(--mono);
400
+ font-weight: 600;
401
+ text-transform: uppercase;
402
+ letter-spacing: .08em;
403
+ color: var(--muted);
404
+ }
405
+
406
+ /* ── Tab toggle ─────────────────────────────────────────── */
407
+ .tab-group {
408
+ display: flex;
409
+ gap: 2px;
410
+ background: var(--surface2);
411
+ border: 1px solid var(--border);
412
+ border-radius: var(--radius);
413
+ padding: 3px;
414
+ }
415
+
416
+ .tab-btn {
417
+ padding: 6px 16px;
418
+ border: none;
419
+ background: transparent;
420
+ border-radius: 6px;
421
+ cursor: pointer;
422
+ font-family: var(--mono);
423
+ font-size: 12px;
424
+ font-weight: 500;
425
+ color: var(--muted);
426
+ transition: all .15s;
427
+ }
428
+
429
+ .tab-btn.active {
430
+ background: var(--surface);
431
+ color: var(--text);
432
+ box-shadow: 0 1px 3px rgba(0,0,0,.4);
433
+ }
434
+
435
+ .tab-btn:hover:not(.active) { color: var(--text); }
436
+
437
+ /* ── Chart panels ───────────────────────────────────────── */
438
+ .chart-panel {
439
+ background: var(--surface);
440
+ border: 1px solid var(--border);
441
+ border-radius: var(--radius-lg);
442
+ padding: 20px 22px;
443
+ }
444
+
445
+ /* Stacked bar chart */
446
+ .bar-chart {
447
+ display: flex;
448
+ align-items: flex-end;
449
+ gap: 6px;
450
+ height: 160px;
451
+ padding-bottom: 28px; /* room for labels */
452
+ position: relative;
453
+ overflow-x: auto;
454
+ }
455
+
456
+ .bar-col {
457
+ display: flex;
458
+ flex-direction: column;
459
+ align-items: center;
460
+ flex: 1;
461
+ min-width: 28px;
462
+ gap: 0;
463
+ height: 100%;
464
+ justify-content: flex-end;
465
+ position: relative;
466
+ cursor: pointer;
467
+ }
468
+
469
+ .bar-stack {
470
+ width: 100%;
471
+ display: flex;
472
+ flex-direction: column-reverse; /* pass on bottom, fail on top */
473
+ border-radius: 3px 3px 0 0;
474
+ overflow: hidden;
475
+ transition: opacity .15s;
476
+ }
477
+
478
+ .bar-col:hover .bar-stack { opacity: .8; }
479
+
480
+ .bar-seg-pass {
481
+ background: var(--pass);
482
+ transition: height .3s ease;
483
+ }
484
+
485
+ .bar-seg-fail {
486
+ background: var(--fail);
487
+ transition: height .3s ease;
488
+ }
489
+
490
+ .bar-seg-skip {
491
+ background: var(--skip);
492
+ opacity: .5;
493
+ transition: height .3s ease;
494
+ }
495
+
496
+ .bar-lbl {
497
+ position: absolute;
498
+ bottom: 0;
499
+ font-size: 10px;
500
+ font-family: var(--mono);
501
+ color: var(--muted);
502
+ white-space: nowrap;
503
+ text-align: center;
504
+ }
505
+
506
+ /* Tooltip */
507
+ .bar-tooltip {
508
+ display: none;
509
+ position: absolute;
510
+ bottom: calc(100% + 6px);
511
+ left: 50%;
512
+ transform: translateX(-50%);
513
+ background: var(--surface2);
514
+ border: 1px solid var(--border2);
515
+ border-radius: var(--radius);
516
+ padding: 8px 10px;
517
+ font-size: 11px;
518
+ font-family: var(--mono);
519
+ white-space: nowrap;
520
+ z-index: 20;
521
+ pointer-events: none;
522
+ line-height: 1.7;
523
+ }
524
+
525
+ .bar-col:hover .bar-tooltip { display: block; }
526
+
527
+ /* Duration line chart */
528
+ .dur-chart {
529
+ height: 80px;
530
+ position: relative;
531
+ overflow: hidden;
532
+ }
533
+
534
+ .dur-chart svg {
535
+ width: 100%;
536
+ height: 100%;
537
+ overflow: visible;
538
+ }
539
+
540
+ /* ── Chart legend ───────────────────────────────────────── */
541
+ .chart-legend {
542
+ display: flex;
543
+ gap: 16px;
544
+ margin-top: 12px;
545
+ padding-top: 12px;
546
+ border-top: 1px solid var(--border);
547
+ }
548
+
549
+ .legend-item {
550
+ display: flex;
551
+ align-items: center;
552
+ gap: 6px;
553
+ font-size: 11px;
554
+ font-family: var(--mono);
555
+ color: var(--muted);
556
+ }
557
+
558
+ .legend-dot {
559
+ width: 8px;
560
+ height: 8px;
561
+ border-radius: 2px;
562
+ flex-shrink: 0;
563
+ }
564
+
565
+ /* ── Two-column layout for charts ───────────────────────── */
566
+ .charts-grid {
567
+ display: grid;
568
+ grid-template-columns: 1fr 320px;
569
+ gap: 12px;
570
+ margin-bottom: 28px;
571
+ }
572
+
573
+ .dur-panel .section-title { margin-bottom: 12px; display: block; }
574
+
575
+ /* ── Individual test trend ──────────────────────────────── */
576
+ .test-view { display: none; }
577
+ .test-view.active { display: block; }
578
+
579
+ .search-box {
580
+ width: 100%;
581
+ padding: 10px 14px;
582
+ background: var(--surface2);
583
+ border: 1px solid var(--border);
584
+ border-radius: var(--radius);
585
+ color: var(--text);
586
+ font-family: var(--sans);
587
+ font-size: 13px;
588
+ margin-bottom: 14px;
589
+ transition: border-color .15s;
590
+ }
591
+
592
+ .search-box:focus {
593
+ outline: none;
594
+ border-color: var(--accent);
595
+ }
596
+
597
+ .search-box::placeholder { color: var(--muted); }
598
+
599
+ .test-grid {
600
+ display: grid;
601
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
602
+ gap: 10px;
603
+ max-height: 420px;
604
+ overflow-y: auto;
605
+ padding-right: 4px;
606
+ }
607
+
608
+ .test-grid::-webkit-scrollbar { width: 4px; }
609
+ .test-grid::-webkit-scrollbar-track { background: transparent; }
610
+ .test-grid::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
611
+
612
+ .test-card {
613
+ background: var(--surface2);
614
+ border: 1px solid var(--border);
615
+ border-radius: var(--radius);
616
+ padding: 12px 14px;
617
+ cursor: pointer;
618
+ transition: border-color .15s, background .15s;
619
+ }
620
+
621
+ .test-card:hover { border-color: var(--border2); background: var(--surface); }
622
+ .test-card.selected { border-color: var(--accent); background: rgba(91,124,246,.07); }
623
+
624
+ .test-card-name {
625
+ font-size: 13px;
626
+ font-weight: 500;
627
+ color: var(--text);
628
+ display: -webkit-box;
629
+ -webkit-line-clamp: 2;
630
+ -webkit-box-orient: vertical;
631
+ overflow: hidden;
632
+ margin-bottom: 8px;
633
+ line-height: 1.4;
634
+ }
635
+
636
+ .test-card-meta {
637
+ display: flex;
638
+ align-items: center;
639
+ justify-content: space-between;
640
+ gap: 8px;
641
+ }
642
+
643
+ .test-card-project {
644
+ font-family: var(--mono);
645
+ font-size: 10px;
646
+ color: var(--muted);
647
+ background: var(--surface);
648
+ border: 1px solid var(--border);
649
+ padding: 2px 6px;
650
+ border-radius: 4px;
651
+ }
652
+
653
+ /* Sparkline */
654
+ .sparkline {
655
+ display: flex;
656
+ gap: 2px;
657
+ align-items: center;
658
+ }
659
+
660
+ .spark-dot {
661
+ width: 7px;
662
+ height: 7px;
663
+ border-radius: 50%;
664
+ flex-shrink: 0;
665
+ }
666
+
667
+ .spark-dot.pass { background: var(--pass); }
668
+ .spark-dot.fail { background: var(--fail); }
669
+ .spark-dot.flaky { background: var(--flaky); }
670
+ .spark-dot.skip { background: var(--skip); opacity: .5; }
671
+ .spark-dot.none { background: var(--border2); }
672
+
673
+ .test-tags {
674
+ display: flex;
675
+ gap: 4px;
676
+ flex-wrap: wrap;
677
+ margin-top: 6px;
678
+ }
679
+
680
+ .tag {
681
+ font-size: 10px;
682
+ font-family: var(--mono);
683
+ padding: 1px 6px;
684
+ border-radius: 4px;
685
+ background: rgba(91,124,246,.15);
686
+ color: var(--accent2);
687
+ border: 1px solid rgba(91,124,246,.25);
688
+ }
689
+
690
+ /* Test trend detail */
691
+ .test-detail {
692
+ margin-top: 16px;
693
+ background: var(--surface);
694
+ border: 1px solid var(--border);
695
+ border-radius: var(--radius-lg);
696
+ padding: 20px 22px;
697
+ }
698
+
699
+ .test-detail-header {
700
+ display: flex;
701
+ align-items: flex-start;
702
+ justify-content: space-between;
703
+ gap: 16px;
704
+ margin-bottom: 16px;
705
+ padding-bottom: 16px;
706
+ border-bottom: 1px solid var(--border);
707
+ }
708
+
709
+ .test-detail-name {
710
+ font-size: 14px;
711
+ font-weight: 600;
712
+ line-height: 1.4;
713
+ }
714
+
715
+ .test-detail-stats {
716
+ display: flex;
717
+ gap: 16px;
718
+ flex-shrink: 0;
719
+ }
720
+
721
+ .test-detail-stat {
722
+ text-align: right;
723
+ }
724
+
725
+ .test-detail-stat-value {
726
+ font-family: var(--mono);
727
+ font-size: 20px;
728
+ font-weight: 600;
729
+ line-height: 1;
730
+ }
731
+
732
+ .test-detail-stat-label {
733
+ font-size: 10px;
734
+ font-family: var(--mono);
735
+ color: var(--muted);
736
+ text-transform: uppercase;
737
+ letter-spacing: .05em;
738
+ }
739
+
740
+ .trend-row {
741
+ border-radius: var(--radius);
742
+ margin-bottom: 4px;
743
+ border-left: 3px solid transparent;
744
+ background: var(--surface2);
745
+ transition: background .15s;
746
+ overflow: hidden;
747
+ }
748
+
749
+ .trend-row:hover { background: var(--surface); }
750
+ .trend-row.pass { border-left-color: var(--pass); }
751
+ .trend-row.fail { border-left-color: var(--fail); }
752
+ .trend-row.flaky { border-left-color: var(--flaky); }
753
+ .trend-row.skip { border-left-color: var(--skip); }
754
+
755
+ .trend-row-summary {
756
+ display: flex;
757
+ align-items: center;
758
+ justify-content: space-between;
759
+ padding: 9px 12px;
760
+ cursor: default;
761
+ }
762
+
763
+ .trend-row.has-detail .trend-row-summary { cursor: pointer; }
764
+
765
+ .trend-row-left { flex: 1; min-width: 0; }
766
+
767
+ .trend-row-date {
768
+ font-family: var(--mono);
769
+ font-size: 12px;
770
+ color: var(--muted);
771
+ }
772
+
773
+ .trend-row-runid {
774
+ font-family: var(--mono);
775
+ font-size: 10px;
776
+ color: var(--border2);
777
+ margin-top: 1px;
778
+ }
779
+
780
+ .trend-row-right {
781
+ display: flex;
782
+ align-items: center;
783
+ gap: 12px;
784
+ flex-shrink: 0;
785
+ }
786
+
787
+ .trend-row-status {
788
+ font-family: var(--mono);
789
+ font-size: 11px;
790
+ font-weight: 600;
791
+ text-transform: uppercase;
792
+ letter-spacing: .05em;
793
+ min-width: 46px;
794
+ text-align: right;
795
+ }
796
+
797
+ .trend-row-dur {
798
+ font-family: var(--mono);
799
+ font-size: 11px;
800
+ color: var(--muted);
801
+ min-width: 48px;
802
+ text-align: right;
803
+ }
804
+
805
+ .trend-row-chevron {
806
+ font-size: 10px;
807
+ color: var(--muted);
808
+ transition: transform .2s;
809
+ width: 14px;
810
+ text-align: center;
811
+ flex-shrink: 0;
812
+ }
813
+
814
+ .trend-row-chevron.open { transform: rotate(180deg); }
815
+
816
+ /* Expandable failure body */
817
+ .trend-row-body {
818
+ display: none;
819
+ padding: 0 12px 12px;
820
+ border-top: 1px solid var(--border);
821
+ }
822
+
823
+ .trend-row-body.open { display: block; }
824
+
825
+ /* Annotation label (from test.info().annotations title) */
826
+ .annot-title {
827
+ font-family: var(--mono);
828
+ font-size: 11px;
829
+ font-weight: 600;
830
+ color: var(--flaky);
831
+ margin-bottom: 4px;
832
+ margin-top: 10px;
833
+ display: flex;
834
+ align-items: center;
835
+ gap: 6px;
836
+ }
837
+
838
+ .annot-desc {
839
+ font-size: 12px;
840
+ color: var(--text);
841
+ margin-bottom: 8px;
842
+ line-height: 1.5;
843
+ }
844
+
845
+ /* Collapsible raw Playwright error */
846
+ .error-toggle {
847
+ display: flex;
848
+ align-items: center;
849
+ gap: 6px;
850
+ font-family: var(--mono);
851
+ font-size: 10px;
852
+ color: var(--muted);
853
+ cursor: pointer;
854
+ user-select: none;
855
+ margin-top: 6px;
856
+ padding: 4px 0;
857
+ }
858
+
859
+ .error-toggle:hover { color: var(--text); }
860
+
861
+ .error-toggle-icon {
862
+ font-size: 9px;
863
+ transition: transform .15s;
864
+ }
865
+
866
+ .error-toggle-icon.open { transform: rotate(90deg); }
867
+
868
+ .trend-row-error {
869
+ font-family: var(--mono);
870
+ font-size: 11px;
871
+ color: var(--fail);
872
+ margin-top: 4px;
873
+ padding: 8px 10px;
874
+ background: var(--fail-bg);
875
+ border-radius: 4px;
876
+ white-space: pre-wrap;
877
+ word-break: break-word;
878
+ display: none;
879
+ }
880
+
881
+ .trend-row-error.open { display: block; }
882
+
883
+ /* Test detail annotation header block */
884
+ .test-annot-block {
885
+ margin-bottom: 16px;
886
+ padding: 14px 16px;
887
+ background: var(--surface2);
888
+ border: 1px solid var(--border);
889
+ border-radius: var(--radius);
890
+ }
891
+
892
+ .test-annot-type {
893
+ font-family: var(--mono);
894
+ font-size: 10px;
895
+ font-weight: 600;
896
+ text-transform: uppercase;
897
+ letter-spacing: .08em;
898
+ color: var(--accent2);
899
+ margin-bottom: 4px;
900
+ }
901
+
902
+ .test-annot-text {
903
+ font-size: 13px;
904
+ color: var(--text);
905
+ line-height: 1.6;
906
+ }
907
+
908
+ /* ── Runs list ──────────────────────────────────────────── */
909
+ .run-card {
910
+ background: var(--surface);
911
+ border: 1px solid var(--border);
912
+ border-radius: var(--radius-lg);
913
+ margin-bottom: 8px;
914
+ overflow: hidden;
915
+ transition: border-color .15s;
916
+ }
917
+
918
+ .run-card:hover { border-color: var(--border2); }
919
+
920
+ .run-card-header {
921
+ display: flex;
922
+ align-items: center;
923
+ justify-content: space-between;
924
+ padding: 14px 18px;
925
+ cursor: pointer;
926
+ gap: 16px;
927
+ }
928
+
929
+ .run-card-left {
930
+ display: flex;
931
+ align-items: center;
932
+ gap: 14px;
933
+ flex: 1;
934
+ min-width: 0;
935
+ }
936
+
937
+ .run-status-dot {
938
+ width: 10px;
939
+ height: 10px;
940
+ border-radius: 50%;
941
+ flex-shrink: 0;
942
+ }
943
+
944
+ .run-card-ts {
945
+ font-family: var(--mono);
946
+ font-size: 12px;
947
+ font-weight: 500;
948
+ color: var(--text);
949
+ white-space: nowrap;
950
+ }
951
+
952
+ .run-badges {
953
+ display: flex;
954
+ gap: 6px;
955
+ flex-wrap: wrap;
956
+ }
957
+
958
+ .badge {
959
+ display: inline-flex;
960
+ align-items: center;
961
+ gap: 4px;
962
+ padding: 3px 8px;
963
+ border-radius: 5px;
964
+ font-family: var(--mono);
965
+ font-size: 11px;
966
+ font-weight: 600;
967
+ white-space: nowrap;
968
+ }
969
+
970
+ .badge.pass { background: var(--pass-bg); color: var(--pass); }
971
+ .badge.fail { background: var(--fail-bg); color: var(--fail); }
972
+ .badge.flaky { background: var(--flaky-bg); color: var(--flaky); }
973
+ .badge.skip { background: var(--skip-bg); color: var(--skip); }
974
+
975
+ .run-card-right {
976
+ display: flex;
977
+ align-items: center;
978
+ gap: 16px;
979
+ flex-shrink: 0;
980
+ }
981
+
982
+ .run-dur {
983
+ font-family: var(--mono);
984
+ font-size: 11px;
985
+ color: var(--muted);
986
+ }
987
+
988
+ .run-chevron {
989
+ color: var(--muted);
990
+ font-size: 11px;
991
+ transition: transform .2s;
992
+ }
993
+
994
+ .run-chevron.open { transform: rotate(180deg); }
995
+
996
+ /* Run detail */
997
+ .run-detail {
998
+ display: none;
999
+ border-top: 1px solid var(--border);
1000
+ padding: 16px 18px;
1001
+ }
1002
+
1003
+ .run-detail.open { display: block; }
1004
+
1005
+ .run-detail-toggle {
1006
+ display: flex;
1007
+ align-items: center;
1008
+ gap: 8px;
1009
+ margin-bottom: 14px;
1010
+ }
1011
+
1012
+ .show-all-btn {
1013
+ font-family: var(--mono);
1014
+ font-size: 11px;
1015
+ color: var(--accent2);
1016
+ background: none;
1017
+ border: none;
1018
+ cursor: pointer;
1019
+ padding: 0;
1020
+ text-decoration: underline;
1021
+ text-underline-offset: 2px;
1022
+ }
1023
+
1024
+ .show-all-btn:hover { color: var(--accent); }
1025
+
1026
+ .detail-label {
1027
+ font-family: var(--mono);
1028
+ font-size: 11px;
1029
+ color: var(--muted);
1030
+ text-transform: uppercase;
1031
+ letter-spacing: .06em;
1032
+ }
1033
+
1034
+ .test-row {
1035
+ display: flex;
1036
+ align-items: flex-start;
1037
+ gap: 10px;
1038
+ padding: 8px 10px;
1039
+ border-radius: var(--radius);
1040
+ border-left: 3px solid transparent;
1041
+ background: var(--surface2);
1042
+ margin-bottom: 4px;
1043
+ }
1044
+
1045
+ .test-row.pass { border-left-color: var(--pass); }
1046
+ .test-row.fail { border-left-color: var(--fail); }
1047
+ .test-row.flaky { border-left-color: var(--flaky); }
1048
+ .test-row.skip { border-left-color: var(--skip); opacity: .7; }
1049
+
1050
+ .test-row-body { flex: 1; min-width: 0; }
1051
+
1052
+ .test-row-title {
1053
+ font-size: 13px;
1054
+ font-weight: 500;
1055
+ white-space: nowrap;
1056
+ overflow: hidden;
1057
+ text-overflow: ellipsis;
1058
+ }
1059
+
1060
+ .test-row-meta {
1061
+ display: flex;
1062
+ gap: 12px;
1063
+ margin-top: 3px;
1064
+ font-family: var(--mono);
1065
+ font-size: 10px;
1066
+ color: var(--muted);
1067
+ }
1068
+
1069
+ .test-row-error {
1070
+ margin-top: 6px;
1071
+ padding: 6px 10px;
1072
+ background: var(--fail-bg);
1073
+ border-radius: 4px;
1074
+ font-family: var(--mono);
1075
+ font-size: 11px;
1076
+ color: var(--fail);
1077
+ white-space: pre-wrap;
1078
+ word-break: break-word;
1079
+ display: none;
1080
+ }
1081
+
1082
+ .test-row-error.open { display: block; }
1083
+
1084
+ .test-row-error-toggle {
1085
+ display: inline-flex;
1086
+ align-items: center;
1087
+ gap: 5px;
1088
+ margin-top: 5px;
1089
+ font-family: var(--mono);
1090
+ font-size: 10px;
1091
+ color: var(--muted);
1092
+ cursor: pointer;
1093
+ user-select: none;
1094
+ background: none;
1095
+ border: none;
1096
+ padding: 0;
1097
+ }
1098
+
1099
+ .test-row-error-toggle:hover { color: var(--fail); }
1100
+
1101
+ .test-row-error-icon {
1102
+ font-size: 9px;
1103
+ transition: transform .15s;
1104
+ display: inline-block;
1105
+ }
1106
+
1107
+ .test-row-error-icon.open { transform: rotate(90deg); }
1108
+
1109
+ .artifact-links {
1110
+ display: flex;
1111
+ gap: 6px;
1112
+ margin-top: 6px;
1113
+ }
1114
+
1115
+ .artifact-link {
1116
+ display: inline-flex;
1117
+ align-items: center;
1118
+ gap: 4px;
1119
+ padding: 3px 8px;
1120
+ background: rgba(91,124,246,.12);
1121
+ border: 1px solid rgba(91,124,246,.25);
1122
+ color: var(--accent2);
1123
+ text-decoration: none;
1124
+ border-radius: 4px;
1125
+ font-family: var(--mono);
1126
+ font-size: 11px;
1127
+ transition: background .15s;
1128
+ }
1129
+
1130
+ .artifact-link:hover { background: rgba(91,124,246,.22); }
1131
+
1132
+ /* ── States ─────────────────────────────────────────────── */
1133
+ .state-screen {
1134
+ display: flex;
1135
+ flex-direction: column;
1136
+ align-items: center;
1137
+ justify-content: center;
1138
+ padding: 80px 20px;
1139
+ color: var(--muted);
1140
+ text-align: center;
1141
+ gap: 12px;
1142
+ }
1143
+
1144
+ .state-screen-icon {
1145
+ font-size: 36px;
1146
+ line-height: 1;
1147
+ }
1148
+
1149
+ .state-screen h3 {
1150
+ font-size: 16px;
1151
+ font-weight: 600;
1152
+ color: var(--text);
1153
+ }
1154
+
1155
+ .state-screen p {
1156
+ font-size: 13px;
1157
+ }
1158
+
1159
+ .spinner {
1160
+ width: 32px;
1161
+ height: 32px;
1162
+ border: 2px solid var(--border2);
1163
+ border-top-color: var(--accent);
1164
+ border-radius: 50%;
1165
+ animation: spin .8s linear infinite;
1166
+ }
1167
+
1168
+ @keyframes spin { to { transform: rotate(360deg); } }
1169
+
1170
+ /* ── Responsive ─────────────────────────────────────────── */
1171
+ @media (max-width: 1024px) {
1172
+ .shell { grid-template-columns: 180px 1fr; }
1173
+ }
1174
+
1175
+ @media (max-width: 768px) {
1176
+ .shell {
1177
+ grid-template-columns: 1fr;
1178
+ grid-template-areas: "topbar" "main";
1179
+ }
1180
+ .sidebar { display: none; }
1181
+ .latest-stats-row { grid-template-columns: repeat(4, 1fr); }
1182
+ .main { padding: 16px 14px 48px; }
1183
+ }
1184
+
1185
+ @media (max-width: 560px) {
1186
+ .latest-stats-row { grid-template-columns: repeat(2, 1fr); }
1187
+ .stats-row { grid-template-columns: 1fr 1fr; }
1188
+ }
1189
+
1190
+ /* ── Test detail sub-panels ─────────────────────────────── */
1191
+ .detail-panels {
1192
+ display: grid;
1193
+ grid-template-columns: 1fr 1fr;
1194
+ gap: 12px;
1195
+ margin-bottom: 16px;
1196
+ }
1197
+
1198
+ @media (max-width: 700px) {
1199
+ .detail-panels { grid-template-columns: 1fr; }
1200
+ }
1201
+
1202
+ .detail-sub-panel {
1203
+ background: var(--surface2);
1204
+ border: 1px solid var(--border);
1205
+ border-radius: var(--radius);
1206
+ padding: 14px 16px;
1207
+ }
1208
+
1209
+ .detail-sub-title {
1210
+ font-family: var(--mono);
1211
+ font-size: 10px;
1212
+ font-weight: 600;
1213
+ text-transform: uppercase;
1214
+ letter-spacing: .08em;
1215
+ color: var(--muted);
1216
+ margin-bottom: 12px;
1217
+ display: flex;
1218
+ align-items: center;
1219
+ justify-content: space-between;
1220
+ }
1221
+
1222
+ /* Duration mini chart */
1223
+ .dur-mini {
1224
+ height: 72px;
1225
+ position: relative;
1226
+ }
1227
+
1228
+ .dur-mini svg {
1229
+ width: 100%;
1230
+ height: 100%;
1231
+ overflow: visible;
1232
+ }
1233
+
1234
+ .dur-mini-meta {
1235
+ display: flex;
1236
+ justify-content: space-between;
1237
+ margin-top: 6px;
1238
+ font-family: var(--mono);
1239
+ font-size: 10px;
1240
+ color: var(--muted);
1241
+ }
1242
+
1243
+ .dur-delta {
1244
+ font-family: var(--mono);
1245
+ font-size: 11px;
1246
+ font-weight: 600;
1247
+ padding: 1px 6px;
1248
+ border-radius: 4px;
1249
+ }
1250
+
1251
+ .dur-delta.up { color: var(--fail); background: var(--fail-bg); }
1252
+ .dur-delta.down { color: var(--pass); background: var(--pass-bg); }
1253
+ .dur-delta.flat { color: var(--muted); background: var(--surface); border: 1px solid var(--border); }
1254
+
1255
+ /* Failure reason bars */
1256
+ .reason-list {
1257
+ display: flex;
1258
+ flex-direction: column;
1259
+ gap: 8px;
1260
+ }
1261
+
1262
+ .reason-row {
1263
+ display: flex;
1264
+ flex-direction: column;
1265
+ gap: 3px;
1266
+ }
1267
+
1268
+ .reason-row-header {
1269
+ display: flex;
1270
+ align-items: center;
1271
+ justify-content: space-between;
1272
+ gap: 8px;
1273
+ }
1274
+
1275
+ .reason-label {
1276
+ font-size: 11px;
1277
+ color: var(--text);
1278
+ white-space: nowrap;
1279
+ overflow: hidden;
1280
+ text-overflow: ellipsis;
1281
+ max-width: 78%;
1282
+ font-family: var(--mono);
1283
+ }
1284
+
1285
+ .reason-count {
1286
+ font-family: var(--mono);
1287
+ font-size: 10px;
1288
+ color: var(--muted);
1289
+ flex-shrink: 0;
1290
+ }
1291
+
1292
+ .reason-bar-track {
1293
+ height: 4px;
1294
+ background: var(--border);
1295
+ border-radius: 2px;
1296
+ overflow: hidden;
1297
+ }
1298
+
1299
+ .reason-bar-fill {
1300
+ height: 100%;
1301
+ background: var(--fail);
1302
+ border-radius: 2px;
1303
+ transition: width .4s ease;
1304
+ }
1305
+
1306
+ .reason-bar-fill.flaky { background: var(--flaky); }
1307
+
1308
+ .no-failures-msg {
1309
+ font-family: var(--mono);
1310
+ font-size: 11px;
1311
+ color: var(--muted);
1312
+ padding: 8px 0;
1313
+ text-align: center;
1314
+ }
1315
+
1316
+ /* ── Health grade ───────────────────────────────────────── */
1317
+ .health-grade {
1318
+ display: inline-flex;
1319
+ align-items: center;
1320
+ justify-content: center;
1321
+ width: 32px;
1322
+ height: 32px;
1323
+ border-radius: 8px;
1324
+ font-family: var(--mono);
1325
+ font-size: 15px;
1326
+ font-weight: 600;
1327
+ flex-shrink: 0;
1328
+ }
1329
+ .grade-A { background: var(--pass-bg); color: var(--pass); }
1330
+ .grade-B { background: rgba(91,124,246,.15); color: var(--accent2); }
1331
+ .grade-C { background: var(--flaky-bg); color: var(--flaky); }
1332
+ .grade-D { background: rgba(242,95,92,.2); color: var(--fail); }
1333
+ .grade-F { background: var(--fail-bg); color: var(--fail); }
1334
+
1335
+ /* ── Slow badge ─────────────────────────────────────────── */
1336
+ .slow-badge {
1337
+ display: inline-flex;
1338
+ align-items: center;
1339
+ padding: 1px 6px;
1340
+ border-radius: 4px;
1341
+ font-family: var(--mono);
1342
+ font-size: 10px;
1343
+ font-weight: 600;
1344
+ background: var(--flaky-bg);
1345
+ color: var(--flaky);
1346
+ margin-left: 6px;
1347
+ flex-shrink: 0;
1348
+ }
1349
+
1350
+ /* ── Page views (dashboard / trends / gallery) ──────────── */
1351
+ .page-view { display: none; }
1352
+ .page-view.active { display: block; }
1353
+
1354
+ /* ── Trends page ────────────────────────────────────────── */
1355
+ .trends-grid {
1356
+ display: grid;
1357
+ grid-template-columns: 1fr 1fr;
1358
+ gap: 14px;
1359
+ margin-bottom: 28px;
1360
+ }
1361
+
1362
+ @media (max-width: 700px) { .trends-grid { grid-template-columns: 1fr; } }
1363
+
1364
+ .trend-mini-panel {
1365
+ background: var(--surface);
1366
+ border: 1px solid var(--border);
1367
+ border-radius: var(--radius-lg);
1368
+ padding: 16px 18px;
1369
+ }
1370
+
1371
+ .trend-mini-title {
1372
+ font-family: var(--mono);
1373
+ font-size: 11px;
1374
+ font-weight: 600;
1375
+ text-transform: uppercase;
1376
+ letter-spacing: .07em;
1377
+ color: var(--muted);
1378
+ margin-bottom: 4px;
1379
+ display: flex;
1380
+ align-items: center;
1381
+ justify-content: space-between;
1382
+ }
1383
+
1384
+ .trend-mini-current {
1385
+ font-family: var(--mono);
1386
+ font-size: 22px;
1387
+ font-weight: 600;
1388
+ line-height: 1;
1389
+ margin-bottom: 10px;
1390
+ }
1391
+
1392
+ .trend-mini-svg {
1393
+ height: 60px;
1394
+ width: 100%;
1395
+ }
1396
+
1397
+ .trend-mini-svg svg { width: 100%; height: 100%; overflow: visible; }
1398
+
1399
+ .trend-mini-footer {
1400
+ display: flex;
1401
+ justify-content: space-between;
1402
+ font-family: var(--mono);
1403
+ font-size: 10px;
1404
+ color: var(--muted);
1405
+ margin-top: 6px;
1406
+ }
1407
+
1408
+ /* ── Sidebar filter chips ───────────────────────────────── */
1409
+ .sidebar-filter-section {
1410
+ padding: 0 12px;
1411
+ margin-bottom: 6px;
1412
+ }
1413
+
1414
+ .sidebar-filter-group-title {
1415
+ font-family: var(--mono);
1416
+ font-size: 10px;
1417
+ font-weight: 600;
1418
+ text-transform: uppercase;
1419
+ letter-spacing: .08em;
1420
+ color: var(--border2);
1421
+ margin: 12px 6px 6px;
1422
+ }
1423
+
1424
+ .sidebar-chips {
1425
+ display: flex;
1426
+ flex-wrap: wrap;
1427
+ gap: 4px;
1428
+ }
1429
+
1430
+ .sidebar-chip {
1431
+ display: inline-flex;
1432
+ align-items: center;
1433
+ gap: 4px;
1434
+ padding: 3px 8px;
1435
+ border-radius: 5px;
1436
+ font-family: var(--mono);
1437
+ font-size: 10px;
1438
+ font-weight: 500;
1439
+ background: var(--surface2);
1440
+ border: 1px solid var(--border);
1441
+ color: var(--muted);
1442
+ cursor: pointer;
1443
+ transition: all .12s;
1444
+ white-space: nowrap;
1445
+ }
1446
+
1447
+ .sidebar-chip:hover { border-color: var(--border2); color: var(--text); }
1448
+ .sidebar-chip.active { background: rgba(91,124,246,.12); border-color: var(--accent); color: var(--accent2); }
1449
+
1450
+ .sidebar-chip-count {
1451
+ background: var(--border);
1452
+ border-radius: 3px;
1453
+ padding: 0 4px;
1454
+ font-size: 9px;
1455
+ }
1456
+
1457
+ .sidebar-chip.active .sidebar-chip-count { background: rgba(91,124,246,.25); }
1458
+
1459
+ /* ── Gallery page ───────────────────────────────────────── */
1460
+ .gallery-empty {
1461
+ text-align: center;
1462
+ padding: 60px 20px;
1463
+ color: var(--muted);
1464
+ font-family: var(--mono);
1465
+ font-size: 13px;
1466
+ }
1467
+
1468
+ .gallery-run-group {
1469
+ margin-bottom: 28px;
1470
+ }
1471
+
1472
+ .gallery-run-header {
1473
+ display: flex;
1474
+ align-items: center;
1475
+ gap: 10px;
1476
+ margin-bottom: 12px;
1477
+ padding-bottom: 10px;
1478
+ border-bottom: 1px solid var(--border);
1479
+ }
1480
+
1481
+ .gallery-run-dot {
1482
+ width: 8px;
1483
+ height: 8px;
1484
+ border-radius: 50%;
1485
+ flex-shrink: 0;
1486
+ }
1487
+
1488
+ .gallery-run-ts {
1489
+ font-family: var(--mono);
1490
+ font-size: 12px;
1491
+ font-weight: 500;
1492
+ color: var(--text);
1493
+ }
1494
+
1495
+ .gallery-run-count {
1496
+ font-family: var(--mono);
1497
+ font-size: 11px;
1498
+ color: var(--muted);
1499
+ }
1500
+
1501
+ .gallery-grid {
1502
+ display: grid;
1503
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
1504
+ gap: 10px;
1505
+ }
1506
+
1507
+ .gallery-item {
1508
+ background: var(--surface2);
1509
+ border: 1px solid var(--border);
1510
+ border-radius: var(--radius);
1511
+ overflow: hidden;
1512
+ cursor: pointer;
1513
+ transition: border-color .15s, transform .1s;
1514
+ }
1515
+
1516
+ .gallery-item:hover {
1517
+ border-color: var(--border2);
1518
+ transform: translateY(-1px);
1519
+ }
1520
+
1521
+ .gallery-img-wrap {
1522
+ width: 100%;
1523
+ aspect-ratio: 16/9;
1524
+ background: var(--bg);
1525
+ display: flex;
1526
+ align-items: center;
1527
+ justify-content: center;
1528
+ overflow: hidden;
1529
+ }
1530
+
1531
+ .gallery-img-wrap img {
1532
+ width: 100%;
1533
+ height: 100%;
1534
+ object-fit: cover;
1535
+ display: block;
1536
+ }
1537
+
1538
+ .gallery-img-placeholder {
1539
+ font-size: 28px;
1540
+ opacity: .3;
1541
+ }
1542
+
1543
+ .gallery-item-info {
1544
+ padding: 8px 10px;
1545
+ }
1546
+
1547
+ .gallery-item-title {
1548
+ font-size: 11px;
1549
+ font-weight: 500;
1550
+ color: var(--text);
1551
+ white-space: nowrap;
1552
+ overflow: hidden;
1553
+ text-overflow: ellipsis;
1554
+ }
1555
+
1556
+ .gallery-item-meta {
1557
+ font-family: var(--mono);
1558
+ font-size: 10px;
1559
+ color: var(--muted);
1560
+ margin-top: 2px;
1561
+ }
1562
+
1563
+ /* Lightbox */
1564
+ .lightbox {
1565
+ display: none;
1566
+ position: fixed;
1567
+ inset: 0;
1568
+ background: rgba(0,0,0,.85);
1569
+ z-index: 100;
1570
+ align-items: center;
1571
+ justify-content: center;
1572
+ flex-direction: column;
1573
+ gap: 12px;
1574
+ }
1575
+
1576
+ .lightbox.open { display: flex; }
1577
+
1578
+ .lightbox img {
1579
+ max-width: 90vw;
1580
+ max-height: 80vh;
1581
+ border-radius: var(--radius);
1582
+ object-fit: contain;
1583
+ }
1584
+
1585
+ .lightbox-title {
1586
+ font-family: var(--mono);
1587
+ font-size: 12px;
1588
+ color: rgba(255,255,255,.7);
1589
+ text-align: center;
1590
+ }
1591
+
1592
+ .lightbox-close {
1593
+ position: fixed;
1594
+ top: 20px;
1595
+ right: 24px;
1596
+ background: none;
1597
+ border: none;
1598
+ color: rgba(255,255,255,.6);
1599
+ font-size: 24px;
1600
+ cursor: pointer;
1601
+ line-height: 1;
1602
+ }
1603
+
1604
+ .lightbox-close:hover { color: #fff; }
1605
+ /* ── Footer ─────────────────────────────────────────────── */
1606
+ .footer {
1607
+ grid-area: footer;
1608
+ padding: 20px 28px;
1609
+ border-top: 1px solid var(--border);
1610
+ display: flex;
1611
+ align-items: center;
1612
+ justify-content: space-between;
1613
+ font-family: var(--mono);
1614
+ font-size: 11px;
1615
+ color: var(--border2);
1616
+ }
1617
+
1618
+ .footer a {
1619
+ color: var(--border2);
1620
+ text-decoration: none;
1621
+ transition: color .15s;
1622
+ }
1623
+
1624
+ .footer a:hover { color: var(--muted); }
1625
+ </style>
1626
+ </head>
1627
+ <body>
1628
+ <div class="shell">
1629
+
1630
+ <!-- Top bar -->
1631
+ <header class="topbar">
1632
+ <div class="topbar-brand">
1633
+ <svg width="22" height="22" viewBox="0 0 22 22" fill="none">
1634
+ <rect x="1" y="1" width="20" height="20" rx="5" stroke="#5b7cf6" stroke-width="1.5"/>
1635
+ <path d="M6 11l3.5 3.5L16 7" stroke="#5b7cf6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
1636
+ </svg>
1637
+ <span id="brandName">pw_dashboard</span>
1638
+ <span id="projectName" style="font-size:11px;font-weight:400;color:var(--muted);padding-left:8px;border-left:1px solid var(--border);margin-left:4px"></span>
1639
+ </div>
1640
+ <div class="topbar-meta" id="topbarMeta">loading…</div>
1641
+ </header>
1642
+
1643
+ <!-- Sidebar -->
1644
+ <nav class="sidebar" id="sidebar">
1645
+ <span class="sidebar-section-label">Navigate</span>
1646
+ <div class="sidebar-item active" id="nav-dashboard" onclick="navTo('dashboard',this)">
1647
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none"><rect x="1" y="1" width="5.5" height="5.5" rx="1.5" fill="currentColor" opacity=".7"/><rect x="8.5" y="1" width="5.5" height="5.5" rx="1.5" fill="currentColor" opacity=".7"/><rect x="1" y="8.5" width="5.5" height="5.5" rx="1.5" fill="currentColor" opacity=".7"/><rect x="8.5" y="8.5" width="5.5" height="5.5" rx="1.5" fill="currentColor" opacity=".4"/></svg>
1648
+ Dashboard
1649
+ </div>
1650
+ <div class="sidebar-item" id="nav-trends" onclick="navTo('trends',this)">
1651
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none"><polyline points="1,12 4,7 7,9 10,4 14,6" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
1652
+ Trends
1653
+ </div>
1654
+ <div class="sidebar-item" id="nav-search" onclick="navTo('search',this)">
1655
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none"><circle cx="6.5" cy="6.5" r="4.5" stroke="currentColor" stroke-width="1.4"/><path d="M10 10l3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
1656
+ Search tests
1657
+ </div>
1658
+ <div class="sidebar-item" id="nav-gallery" onclick="navTo('gallery',this)">
1659
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none"><rect x="1" y="1" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.3"/><rect x="8" y="1" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.3"/><rect x="1" y="8" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.3"/><rect x="8" y="8" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.3"/></svg>
1660
+ Gallery
1661
+ </div>
1662
+
1663
+ <div class="sidebar-divider"></div>
1664
+ <span class="sidebar-section-label">Filters</span>
1665
+ <div class="sidebar-filter-section" id="sidebarFilters">
1666
+ <div style="font-family:var(--mono);font-size:11px;color:var(--border2);padding:4px 6px">loading…</div>
1667
+ </div>
1668
+
1669
+ <div class="sidebar-divider"></div>
1670
+ <span class="sidebar-section-label">Recent runs</span>
1671
+ <div id="sidebarRuns">
1672
+ <div style="padding:8px 18px;font-family:var(--mono);font-size:11px;color:var(--border2)">loading…</div>
1673
+ </div>
1674
+ </nav>
1675
+
1676
+ <!-- Lightbox -->
1677
+ <div class="lightbox" id="lightbox" onclick="closeLightbox()">
1678
+ <button class="lightbox-close" onclick="closeLightbox()">✕</button>
1679
+ <img id="lightboxImg" src="" alt="">
1680
+ <div class="lightbox-title" id="lightboxTitle"></div>
1681
+ </div>
1682
+
1683
+ <!-- Main content -->
1684
+ <main class="main" id="mainContent">
1685
+ <div class="state-screen">
1686
+ <div class="spinner"></div>
1687
+ <p>Loading test history…</p>
1688
+ </div>
1689
+ </main>
1690
+
1691
+ <!-- Footer -->
1692
+ <footer class="footer">
1693
+ <span>created by <a href="https://github.com/acahet" target="_blank" rel="noopener">acahet</a></span>
1694
+ <span id="footerProject"></span>
1695
+ </footer>
1696
+
1697
+ </div>
1698
+
1699
+ <script>
1700
+ // ── Utilities ──────────────────────────────────────────────────────────────
1701
+ function esc(str) {
1702
+ const d = document.createElement('div');
1703
+ d.textContent = str ?? '';
1704
+ return d.innerHTML;
1705
+ }
1706
+
1707
+ function fmtDate(ts) {
1708
+ return new Date(ts).toLocaleString(undefined, {
1709
+ month: 'short', day: 'numeric',
1710
+ hour: '2-digit', minute: '2-digit'
1711
+ });
1712
+ }
1713
+
1714
+ function fmtDur(ms) {
1715
+ if (ms < 1000) return ms + 'ms';
1716
+ if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
1717
+ const m = Math.floor(ms / 60000);
1718
+ const s = ((ms % 60000) / 1000).toFixed(0).padStart(2, '0');
1719
+ return `${m}m ${s}s`;
1720
+ }
1721
+
1722
+ function statusColor(s) {
1723
+ return { passed:'var(--pass)', failed:'var(--fail)', flaky:'var(--flaky)', skipped:'var(--skip)' }[s] ?? 'var(--skip)';
1724
+ }
1725
+
1726
+ function statusClass(s) {
1727
+ return { passed:'pass', failed:'fail', flaky:'flaky', skipped:'skip' }[s] ?? 'skip';
1728
+ }
1729
+
1730
+ // ─────────────────────────────────────────────────────────────────────────────
1731
+ // CONFIG — edit these values to customise the dashboard for your project
1732
+ // ─────────────────────────────────────────────────────────────────────────────
1733
+ const CONFIG = {
1734
+ // Displayed in the browser tab
1735
+ pageTitle: 'Test History Dashboard',
1736
+
1737
+ // Your project name — shown in the topbar next to the dashboard brand
1738
+ projectName: 'My Project',
1739
+
1740
+ // Brand name shown in the top-left of the sidebar
1741
+ brandName: 'pw_dashboard',
1742
+
1743
+ // Path to the JSON index file (relative to this HTML file)
1744
+ historyIndexPath: 'history-index.json',
1745
+
1746
+ // Empty state messages
1747
+ emptyTitle: 'No test history yet',
1748
+ emptyMessage: 'Run your tests to start tracking history.',
1749
+
1750
+ // Error state messages
1751
+ errorTitle: 'Could not load history',
1752
+ errorMessage: 'Make sure history-index.json is in the same directory.',
1753
+ };
1754
+ // ─────────────────────────────────────────────────────────────────────────────
1755
+ let runs = [];
1756
+ let allTests = [];
1757
+ let selectedTestKey = null;
1758
+ let activeFilter = null; // 'passed' | 'failed' | 'flaky' | 'skipped' | null
1759
+ let sidebarTagFilter = null;
1760
+ let sidebarSpecFilter = null;
1761
+ let currentPage = 'dashboard'; // 'dashboard' | 'trends' | 'gallery'
1762
+
1763
+ // ── Health grade ───────────────────────────────────────────────────────────
1764
+ function computeGrade(run) {
1765
+ if (!run.totalTests) return 'F';
1766
+ const passRate = run.passed / run.totalTests;
1767
+ const stability = 1 - ((run.flaky ?? 0) / run.totalTests);
1768
+ const allT = run.allTests ?? [];
1769
+ const p75 = computeP75(allT.map(t => t.duration));
1770
+ const slowCount = allT.filter(t => t.duration > p75 * 1.5).length;
1771
+ const perfScore = 1 - (slowCount / run.totalTests);
1772
+ const score = (passRate * 0.6 + stability * 0.3 + perfScore * 0.1) * 100;
1773
+ if (score >= 95) return 'A';
1774
+ if (score >= 85) return 'B';
1775
+ if (score >= 70) return 'C';
1776
+ if (score >= 50) return 'D';
1777
+ return 'F';
1778
+ }
1779
+
1780
+ function computeP75(durations) {
1781
+ if (!durations.length) return 0;
1782
+ const sorted = [...durations].sort((a, b) => a - b);
1783
+ return sorted[Math.floor(sorted.length * 0.75)];
1784
+ }
1785
+
1786
+ function isSlowTest(t, p75) { return t.duration > p75 * 1.5; }
1787
+
1788
+ // ── Bootstrap ──────────────────────────────────────────────────────────────
1789
+ async function init() {
1790
+ // Apply config values that need JS to set
1791
+ document.title = CONFIG.pageTitle;
1792
+ document.getElementById('brandName').textContent = CONFIG.brandName;
1793
+ const pn = document.getElementById('projectName');
1794
+ if (pn && CONFIG.projectName) pn.textContent = CONFIG.projectName;
1795
+ const fp = document.getElementById('footerProject');
1796
+ if (fp && CONFIG.projectName) fp.textContent = CONFIG.projectName;
1797
+
1798
+ try {
1799
+ const res = await fetch(`${CONFIG.historyIndexPath}?ts=${Date.now()}`, {
1800
+ cache: 'no-store'
1801
+ });
1802
+ if (!res.ok) throw new Error('fetch failed');
1803
+ const data = await res.json();
1804
+ if (!data.runs?.length) { renderEmpty(); return; }
1805
+ runs = data.runs;
1806
+ render();
1807
+ } catch {
1808
+ renderError();
1809
+ }
1810
+ }
1811
+
1812
+ function renderEmpty() {
1813
+ document.getElementById('mainContent').innerHTML = `
1814
+ <div class="state-screen">
1815
+ <div class="state-screen-icon">🎭</div>
1816
+ <h3>${esc(CONFIG.emptyTitle)}</h3>
1817
+ <p>${esc(CONFIG.emptyMessage)}</p>
1818
+ </div>`;
1819
+ }
1820
+
1821
+ function renderError() {
1822
+ document.getElementById('mainContent').innerHTML = `
1823
+ <div class="state-screen">
1824
+ <div class="state-screen-icon">⚠️</div>
1825
+ <h3>${esc(CONFIG.errorTitle)}</h3>
1826
+ <p>${esc(CONFIG.errorMessage)}</p>
1827
+ </div>`;
1828
+ }
1829
+
1830
+ // ── Main render ────────────────────────────────────────────────────────────
1831
+ function render() {
1832
+ const latest = runs[0];
1833
+ const totalRuns = runs.length;
1834
+ const avgPass = runs.reduce((s, r) =>
1835
+ s + (r.totalTests > 0 ? r.passed / r.totalTests * 100 : 0), 0) / runs.length;
1836
+ const latestRate = latest.totalTests > 0
1837
+ ? (latest.passed / latest.totalTests * 100).toFixed(1)
1838
+ : '0.0';
1839
+
1840
+ document.getElementById('topbarMeta').textContent =
1841
+ `${totalRuns} runs · last: ${fmtDate(latest.timestamp)}`;
1842
+
1843
+ buildTestIndex();
1844
+ renderSidebar();
1845
+ renderSidebarFilters();
1846
+ renderMainContent(latest, totalRuns, avgPass, latestRate);
1847
+ }
1848
+
1849
+ function renderMainContent(latest, totalRuns, avgPass, latestRate) {
1850
+ document.getElementById('mainContent').innerHTML = `
1851
+ <!-- Latest run stat cards -->
1852
+ <div class="latest-run-section">
1853
+ <div class="latest-run-header">
1854
+ <div style="display:flex;align-items:center;gap:10px">
1855
+ <span class="health-grade grade-${computeGrade(latest)}" title="Suite health grade">${computeGrade(latest)}</span>
1856
+ <span class="latest-run-title">Latest run</span>
1857
+ </div>
1858
+ <span class="latest-run-ts">${fmtDate(latest.timestamp)} · ${fmtDur(latest.duration)}</span>
1859
+ </div>
1860
+ <div class="latest-stats-row">
1861
+ <div class="latest-stat rate-card clickable ${activeFilter===null?'active-filter':''}"
1862
+ onclick="clearFilter()" title="Show all tests">
1863
+ <span class="latest-stat-label">Pass rate · ALL</span>
1864
+ <span class="latest-stat-value" style="color:${latestRate>=80?'var(--pass)':latestRate>=50?'var(--flaky)':'var(--fail)'}">${latestRate}<span style="font-size:16px">%</span></span>
1865
+ </div>
1866
+ <div class="latest-stat pass-card ${latest.passed>0?'clickable':''} ${activeFilter==='passed'?'active-filter':''}"
1867
+ ${latest.passed>0?`onclick="filterLatestRun('passed')" title="Filter: passed"`:''}>
1868
+ <span class="latest-stat-label">Passed</span>
1869
+ <span class="latest-stat-value" style="color:var(--pass)">${latest.passed}</span>
1870
+ </div>
1871
+ <div class="latest-stat fail-card ${latest.failed>0?'clickable':''} ${activeFilter==='failed'?'active-filter':''}"
1872
+ ${latest.failed>0?`onclick="filterLatestRun('failed')" title="Filter: failed"`:''}>
1873
+ <span class="latest-stat-label">Failed</span>
1874
+ <span class="latest-stat-value" style="color:${latest.failed>0?'var(--fail)':'var(--muted)'}">${latest.failed}</span>
1875
+ </div>
1876
+ <div class="latest-stat flaky-card ${(latest.flaky??0)>0?'clickable':''} ${activeFilter==='flaky'?'active-filter':''}"
1877
+ ${(latest.flaky??0)>0?`onclick="filterLatestRun('flaky')" title="Filter: flaky"`:''}>
1878
+ <span class="latest-stat-label">Flaky</span>
1879
+ <span class="latest-stat-value" style="color:${(latest.flaky??0)>0?'var(--flaky)':'var(--muted)'}">${latest.flaky ?? 0}</span>
1880
+ </div>
1881
+ <div class="latest-stat skip-card ${latest.skipped>0?'clickable':''} ${activeFilter==='skipped'?'active-filter':''}"
1882
+ ${latest.skipped>0?`onclick="filterLatestRun('skipped')" title="Filter: skipped"`:''}>
1883
+ <span class="latest-stat-label">Skipped</span>
1884
+ <span class="latest-stat-value" style="color:var(--muted)">${latest.skipped}</span>
1885
+ </div>
1886
+ <div class="latest-stat total-card">
1887
+ <span class="latest-stat-label">Total</span>
1888
+ <span class="latest-stat-value">${latest.totalTests}</span>
1889
+ </div>
1890
+ <div class="latest-stat dur-card">
1891
+ <span class="latest-stat-label">Duration</span>
1892
+ <span class="latest-stat-value" style="color:var(--accent);font-size:20px">${fmtDur(latest.duration)}</span>
1893
+ </div>
1894
+ </div>
1895
+ </div>
1896
+
1897
+ <!-- Trend + duration charts -->
1898
+ <div class="charts-grid">
1899
+ <div class="section">
1900
+ <div class="section-header">
1901
+ <span class="section-title">Test results trend</span>
1902
+ <div class="tab-group">
1903
+ <button class="tab-btn active" onclick="switchTab('overall',this)">Overall</button>
1904
+ <button class="tab-btn" onclick="switchTab('test',this)">Per test</button>
1905
+ </div>
1906
+ </div>
1907
+
1908
+ <!-- Overall trend (stacked bars) -->
1909
+ <div id="overallView">
1910
+ <div class="chart-panel">
1911
+ <div class="bar-chart" id="barChart"></div>
1912
+ <div class="chart-legend">
1913
+ <div class="legend-item"><span class="legend-dot" style="background:var(--pass)"></span>Passed</div>
1914
+ <div class="legend-item"><span class="legend-dot" style="background:var(--fail)"></span>Failed</div>
1915
+ <div class="legend-item"><span class="legend-dot" style="background:var(--skip);opacity:.5"></span>Skipped</div>
1916
+ </div>
1917
+ </div>
1918
+ </div>
1919
+
1920
+ <!-- Per-test search + grid (no detail here) -->
1921
+ <div id="testView" class="test-view">
1922
+ <input class="search-box" id="testSearch" placeholder="Search tests, projects, tags…" oninput="filterTests()">
1923
+ <div class="test-grid" id="testGrid"></div>
1924
+ <div style="margin-top:10px;font-family:var(--mono);font-size:11px;color:var(--muted)" id="testSelectHint">
1925
+ ↑ select a test to see its full history
1926
+ </div>
1927
+ </div>
1928
+ </div>
1929
+
1930
+ <!-- Duration panel -->
1931
+ <div class="section">
1932
+ <div class="section-header">
1933
+ <span class="section-title">Run duration</span>
1934
+ </div>
1935
+ <div class="chart-panel dur-panel">
1936
+ <div class="dur-chart" id="durChart"></div>
1937
+ <div class="chart-legend" style="margin-top:8px;padding-top:8px">
1938
+ <div class="legend-item"><span class="legend-dot" style="background:var(--accent)"></span>Duration</div>
1939
+ </div>
1940
+ </div>
1941
+ </div>
1942
+ </div>
1943
+
1944
+ <!-- Test detail — full width, below the charts grid -->
1945
+ <div id="testDetail" style="margin-bottom:28px"></div>
1946
+
1947
+ <!-- Runs list -->
1948
+ <div class="section">
1949
+ <div class="section-header">
1950
+ <span class="section-title">Recent runs (${totalRuns})</span>
1951
+ </div>
1952
+ <div id="runsList"></div>
1953
+ </div>
1954
+ `;
1955
+
1956
+ renderBarChart();
1957
+ renderDurChart();
1958
+ renderRunsList();
1959
+ renderTestGrid(allTests);
1960
+ }
1961
+
1962
+ // ── Sidebar ────────────────────────────────────────────────────────────────
1963
+ function renderSidebar() {
1964
+ const el = document.getElementById('sidebarRuns');
1965
+ if (!el) return;
1966
+
1967
+ el.innerHTML = runs.slice(0, 15).map((run, idx) => {
1968
+ const isPass = run.failed === 0;
1969
+ const rate = run.totalTests > 0 ? Math.round(run.passed / run.totalTests * 100) : 0;
1970
+ return `
1971
+ <div class="sidebar-run ${idx === 0 ? 'active' : ''}"
1972
+ onclick="jumpToRun(${idx})">
1973
+ <span class="sidebar-run-dot" style="background:${isPass ? 'var(--pass)' : 'var(--fail)'}"></span>
1974
+ <div class="sidebar-run-info">
1975
+ <div class="sidebar-run-date">${fmtDate(run.timestamp)}</div>
1976
+ <div class="sidebar-run-stat">${rate}% · ${fmtDur(run.duration)}</div>
1977
+ </div>
1978
+ </div>`;
1979
+ }).join('');
1980
+ }
1981
+
1982
+ window.navTo = function(view, el) {
1983
+ document.querySelectorAll('.sidebar-item').forEach(i => i.classList.remove('active'));
1984
+ el.classList.add('active');
1985
+ currentPage = view;
1986
+
1987
+ const mc = document.getElementById('mainContent');
1988
+
1989
+ if (view === 'dashboard') {
1990
+ render();
1991
+ window.scrollTo({ top: 0, behavior: 'smooth' });
1992
+ } else if (view === 'trends') {
1993
+ mc.innerHTML = '<div id="trendsPage"></div>';
1994
+ renderTrendsPage();
1995
+ window.scrollTo({ top: 0, behavior: 'smooth' });
1996
+ } else if (view === 'gallery') {
1997
+ mc.innerHTML = '<div id="galleryPage"></div>';
1998
+ renderGalleryPage();
1999
+ window.scrollTo({ top: 0, behavior: 'smooth' });
2000
+ } else if (view === 'search') {
2001
+ render();
2002
+ setTimeout(() => {
2003
+ const testBtn = document.querySelectorAll('.tab-btn')[1];
2004
+ if (testBtn) switchTab('test', testBtn);
2005
+ document.getElementById('testSearch')?.focus();
2006
+ }, 50);
2007
+ }
2008
+ };
2009
+
2010
+ // ── Sidebar filters ────────────────────────────────────────────────────────
2011
+ function renderSidebarFilters() {
2012
+ const el = document.getElementById('sidebarFilters');
2013
+ if (!el) return;
2014
+
2015
+ // Collect tags across all runs
2016
+ const tagCounts = new Map();
2017
+ const specCounts = new Map();
2018
+ runs.forEach(run => {
2019
+ (run.allTests ?? []).forEach(t => {
2020
+ (t.tags ?? []).forEach(tag => tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1));
2021
+ const spec = t.file?.split('/').pop() ?? t.file;
2022
+ if (spec) specCounts.set(spec, (specCounts.get(spec) ?? 0) + 1);
2023
+ });
2024
+ });
2025
+
2026
+ const tags = [...tagCounts.entries()].sort((a,b) => b[1]-a[1]).slice(0, 12);
2027
+ const specs = [...specCounts.entries()].sort((a,b) => b[1]-a[1]).slice(0, 8);
2028
+
2029
+ el.innerHTML = `
2030
+ ${tags.length ? `
2031
+ <div class="sidebar-filter-group-title">Tags</div>
2032
+ <div class="sidebar-chips">
2033
+ ${tags.map(([tag, cnt]) => `
2034
+ <div class="sidebar-chip ${sidebarTagFilter===tag?'active':''}"
2035
+ onclick="toggleTagFilter(${JSON.stringify(tag)})">
2036
+ ${esc(tag)}
2037
+ <span class="sidebar-chip-count">${cnt}</span>
2038
+ </div>`).join('')}
2039
+ </div>` : ''}
2040
+ ${specs.length ? `
2041
+ <div class="sidebar-filter-group-title">Specs</div>
2042
+ <div class="sidebar-chips">
2043
+ ${specs.map(([spec, cnt]) => `
2044
+ <div class="sidebar-chip ${sidebarSpecFilter===spec?'active':''}"
2045
+ onclick="toggleSpecFilter(${JSON.stringify(spec)})">
2046
+ ${esc(spec)}
2047
+ <span class="sidebar-chip-count">${cnt}</span>
2048
+ </div>`).join('')}
2049
+ </div>` : ''}`;
2050
+ }
2051
+
2052
+ window.toggleTagFilter = function(tag) {
2053
+ sidebarTagFilter = sidebarTagFilter === tag ? null : tag;
2054
+ sidebarSpecFilter = null;
2055
+ renderSidebarFilters();
2056
+ // Switch to search view and apply filter
2057
+ const searchBtn = document.getElementById('nav-search');
2058
+ if (searchBtn) navTo('search', searchBtn);
2059
+ setTimeout(() => {
2060
+ const input = document.getElementById('testSearch');
2061
+ if (input) { input.value = tag; filterTests(); }
2062
+ }, 80);
2063
+ };
2064
+
2065
+ window.toggleSpecFilter = function(spec) {
2066
+ sidebarSpecFilter = sidebarSpecFilter === spec ? null : spec;
2067
+ sidebarTagFilter = null;
2068
+ renderSidebarFilters();
2069
+ const searchBtn = document.getElementById('nav-search');
2070
+ if (searchBtn) navTo('search', searchBtn);
2071
+ setTimeout(() => {
2072
+ const input = document.getElementById('testSearch');
2073
+ if (input) { input.value = spec; filterTests(); }
2074
+ }, 80);
2075
+ };
2076
+
2077
+ // ── Trends page ────────────────────────────────────────────────────────────
2078
+ function renderTrendsPage() {
2079
+ const el = document.getElementById('trendsPage');
2080
+ if (!el) return;
2081
+
2082
+ const display = runs.slice(0, 30).reverse();
2083
+
2084
+ const passRates = display.map(r => r.totalTests > 0 ? (r.passed / r.totalTests * 100) : 0);
2085
+ const durations = display.map(r => r.duration);
2086
+ const flakyCnts = display.map(r => r.flaky ?? 0);
2087
+ const slowCnts = display.map(r => {
2088
+ const allT = r.allTests ?? [];
2089
+ const p75 = computeP75(allT.map(t => t.duration));
2090
+ return allT.filter(t => isSlowTest(t, p75)).length;
2091
+ });
2092
+
2093
+ el.innerHTML = `
2094
+ <div class="section-header" style="margin-bottom:20px">
2095
+ <span class="section-title">Trends · last ${display.length} runs</span>
2096
+ </div>
2097
+ <div class="trends-grid">
2098
+ ${buildTrendMiniPanel('Pass rate', passRates, display, v => v.toFixed(1)+'%', 'var(--pass)', true)}
2099
+ ${buildTrendMiniPanel('Duration', durations, display, v => fmtDur(v), 'var(--accent)', false)}
2100
+ ${buildTrendMiniPanel('Flaky tests', flakyCnts, display, v => v+'', 'var(--flaky)', false)}
2101
+ ${buildTrendMiniPanel('Slow tests', slowCnts, display, v => v+'', '#a78bfa', false)}
2102
+ </div>`;
2103
+ }
2104
+
2105
+ function buildTrendMiniPanel(label, values, display, fmt, color, higherIsBetter) {
2106
+ const current = values[values.length - 1] ?? 0;
2107
+ const prev = values[values.length - 2] ?? current;
2108
+ const delta = prev !== 0 ? ((current - prev) / Math.abs(prev) * 100) : 0;
2109
+ const deltaGood = higherIsBetter ? delta >= 0 : delta <= 0;
2110
+ const deltaStr = delta === 0 ? '~ stable'
2111
+ : `${delta > 0 ? '+' : ''}${delta.toFixed(0)}% vs prev`;
2112
+ const deltaColor = delta === 0 ? 'var(--muted)' : deltaGood ? 'var(--pass)' : 'var(--fail)';
2113
+
2114
+ if (values.length < 2) {
2115
+ return `<div class="trend-mini-panel">
2116
+ <div class="trend-mini-title">${label}</div>
2117
+ <div class="trend-mini-current" style="color:${color}">${fmt(current)}</div>
2118
+ <div style="color:var(--muted);font-family:var(--mono);font-size:11px">Not enough data</div>
2119
+ </div>`;
2120
+ }
2121
+
2122
+ const W = 280, H = 55;
2123
+ const minV = Math.min(...values);
2124
+ const maxV = Math.max(...values);
2125
+ const range = maxV - minV || 1;
2126
+
2127
+ const pts = values.map((v, i) => {
2128
+ const x = (i / (values.length - 1)) * W;
2129
+ const y = H - ((v - minV) / range) * (H - 8) - 4;
2130
+ return [x.toFixed(1), y.toFixed(1)];
2131
+ });
2132
+
2133
+ const polyline = pts.map(p => p.join(',')).join(' ');
2134
+ const areaPath = `M${pts[0][0]},${H} ${pts.map(p=>`L${p[0]},${p[1]}`).join(' ')} L${pts[pts.length-1][0]},${H} Z`;
2135
+
2136
+ const dotsSvg = pts.map((p, i) =>
2137
+ `<circle cx="${p[0]}" cy="${p[1]}" r="2.5" fill="${color}" opacity=".85">
2138
+ <title>${fmt(values[i])} · ${fmtDate(display[i].timestamp)}</title>
2139
+ </circle>`
2140
+ ).join('');
2141
+
2142
+ return `
2143
+ <div class="trend-mini-panel">
2144
+ <div class="trend-mini-title">
2145
+ ${label}
2146
+ <span style="color:${deltaColor};font-size:10px">${deltaStr}</span>
2147
+ </div>
2148
+ <div class="trend-mini-current" style="color:${color}">${fmt(current)}</div>
2149
+ <div class="trend-mini-svg">
2150
+ <svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
2151
+ <defs>
2152
+ <linearGradient id="tg-${label.replace(/\s/g,'')}" x1="0" y1="0" x2="0" y2="1">
2153
+ <stop offset="0%" stop-color="${color}" stop-opacity=".2"/>
2154
+ <stop offset="100%" stop-color="${color}" stop-opacity="0"/>
2155
+ </linearGradient>
2156
+ </defs>
2157
+ <path d="${areaPath}" fill="url(#tg-${label.replace(/\s/g,'')})"/>
2158
+ <polyline points="${polyline}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
2159
+ ${dotsSvg}
2160
+ </svg>
2161
+ </div>
2162
+ <div class="trend-mini-footer">
2163
+ <span>min ${fmt(Math.min(...values))}</span>
2164
+ <span>avg ${fmt(values.reduce((a,b)=>a+b,0)/values.length)}</span>
2165
+ <span>max ${fmt(Math.max(...values))}</span>
2166
+ </div>
2167
+ </div>`;
2168
+ }
2169
+
2170
+ // ── Gallery page ────────────────────────────────────────────────────────────
2171
+ function renderGalleryPage() {
2172
+ const el = document.getElementById('galleryPage');
2173
+ if (!el) return;
2174
+
2175
+ // Collect all screenshots across all runs
2176
+ const groups = runs.map(run => {
2177
+ const shots = (run.allTests ?? []).filter(t => t.artifacts?.screenshot);
2178
+ return { run, shots };
2179
+ }).filter(g => g.shots.length > 0);
2180
+
2181
+ if (!groups.length) {
2182
+ el.innerHTML = `
2183
+ <div class="section-header" style="margin-bottom:20px">
2184
+ <span class="section-title">Screenshots gallery</span>
2185
+ </div>
2186
+ <div class="gallery-empty">
2187
+ No screenshots found.<br>Screenshots are captured on failure — run tests with <code>screenshot: 'on'</code> or <code>'on-first-retry'</code>.
2188
+ </div>`;
2189
+ return;
2190
+ }
2191
+
2192
+ const totalShots = groups.reduce((s, g) => s + g.shots.length, 0);
2193
+
2194
+ el.innerHTML = `
2195
+ <div class="section-header" style="margin-bottom:20px">
2196
+ <span class="section-title">Screenshots gallery · ${totalShots} image${totalShots !== 1 ? 's' : ''}</span>
2197
+ </div>
2198
+ ${groups.map(({ run, shots }) => `
2199
+ <div class="gallery-run-group">
2200
+ <div class="gallery-run-header">
2201
+ <span class="gallery-run-dot" style="background:${run.failed===0?'var(--pass)':'var(--fail)'}"></span>
2202
+ <span class="gallery-run-ts">${fmtDate(run.timestamp)}</span>
2203
+ <span class="gallery-run-count">${shots.length} screenshot${shots.length!==1?'s':''}</span>
2204
+ </div>
2205
+ <div class="gallery-grid">
2206
+ ${shots.map(t => `
2207
+ <div class="gallery-item" onclick="openLightbox('${esc(t.artifacts.screenshot)}','${esc(t.title)}')">
2208
+ <div class="gallery-img-wrap">
2209
+ <img src="${esc(t.artifacts.screenshot)}" alt="${esc(t.title)}"
2210
+ onerror="this.parentElement.innerHTML='<span class=\\'gallery-img-placeholder\\'>📷</span>'">
2211
+ </div>
2212
+ <div class="gallery-item-info">
2213
+ <div class="gallery-item-title">${esc(t.title)}</div>
2214
+ <div class="gallery-item-meta">${esc(t.project)} · ${fmtDur(t.duration)}</div>
2215
+ </div>
2216
+ </div>`).join('')}
2217
+ </div>
2218
+ </div>`).join('')}`;
2219
+ }
2220
+
2221
+ window.openLightbox = function(src, title) {
2222
+ document.getElementById('lightboxImg').src = src;
2223
+ document.getElementById('lightboxTitle').textContent = title;
2224
+ document.getElementById('lightbox').classList.add('open');
2225
+ };
2226
+
2227
+ window.closeLightbox = function() {
2228
+ document.getElementById('lightbox').classList.remove('open');
2229
+ document.getElementById('lightboxImg').src = '';
2230
+ };
2231
+
2232
+ document.addEventListener('keydown', e => {
2233
+ if (e.key === 'Escape') closeLightbox();
2234
+ });
2235
+
2236
+ window.jumpToRun = function(idx) {
2237
+ // Highlight sidebar item
2238
+ document.querySelectorAll('.sidebar-run').forEach((r, i) => r.classList.toggle('active', i === idx));
2239
+ // Open that run card in the list
2240
+ const card = document.getElementById(`run-${idx}`);
2241
+ if (!card) return;
2242
+ card.scrollIntoView({ behavior: 'smooth', block: 'start' });
2243
+ // Auto-open if not already open
2244
+ const detail = document.getElementById(`detail-${idx}`);
2245
+ if (detail && !detail.classList.contains('open')) toggleRun(idx);
2246
+ };
2247
+
2248
+ // ── Stacked bar chart ──────────────────────────────────────────────────────
2249
+ function renderBarChart() {
2250
+ const el = document.getElementById('barChart');
2251
+ const display = runs.slice(0, 30).reverse();
2252
+ const maxTotal = Math.max(...display.map(r => r.totalTests), 1);
2253
+
2254
+ el.innerHTML = display.map(r => {
2255
+ const pct = v => ((v / maxTotal) * 132).toFixed(1); // 132px usable height
2256
+ const passH = pct(r.passed);
2257
+ const failH = pct(r.failed);
2258
+ const skipH = pct(r.skipped);
2259
+ const stackH = (+passH + +failH + +skipH);
2260
+ const date = fmtDate(r.timestamp).split(',')[0];
2261
+ const rate = r.totalTests > 0 ? (r.passed / r.totalTests * 100).toFixed(0) : 0;
2262
+
2263
+ return `
2264
+ <div class="bar-col">
2265
+ <div class="bar-tooltip">
2266
+ <div style="color:var(--pass)">${r.passed} passed</div>
2267
+ ${r.failed > 0 ? `<div style="color:var(--fail)">${r.failed} failed</div>` : ''}
2268
+ ${r.flaky > 0 ? `<div style="color:var(--flaky)">${r.flaky} flaky</div>` : ''}
2269
+ ${r.skipped > 0 ? `<div style="color:var(--skip)">${r.skipped} skipped</div>` : ''}
2270
+ <div style="color:var(--muted);margin-top:2px">${rate}% pass · ${fmtDur(r.duration)}</div>
2271
+ </div>
2272
+ <div class="bar-stack" style="height:${stackH}px">
2273
+ ${r.skipped > 0 ? `<div class="bar-seg-skip" style="height:${skipH}px"></div>` : ''}
2274
+ ${r.failed > 0 ? `<div class="bar-seg-fail" style="height:${failH}px"></div>` : ''}
2275
+ <div class="bar-seg-pass" style="height:${passH}px"></div>
2276
+ </div>
2277
+ <div class="bar-lbl">${date}</div>
2278
+ </div>`;
2279
+ }).join('');
2280
+ }
2281
+
2282
+ // ── Duration line chart (SVG) ─────────────────────────────────────────────
2283
+ function renderDurChart() {
2284
+ const el = document.getElementById('durChart');
2285
+ const display = runs.slice(0, 30).reverse();
2286
+ if (display.length < 2) { el.innerHTML = '<div style="color:var(--muted);font-size:12px;padding:8px">Not enough data yet</div>'; return; }
2287
+
2288
+ const W = 300, H = 70;
2289
+ const durations = display.map(r => r.duration);
2290
+ const minD = Math.min(...durations);
2291
+ const maxD = Math.max(...durations);
2292
+ const range = maxD - minD || 1;
2293
+
2294
+ const pts = display.map((r, i) => {
2295
+ const x = (i / (display.length - 1)) * W;
2296
+ const y = H - ((r.duration - minD) / range) * (H - 10) - 4;
2297
+ return [x.toFixed(1), y.toFixed(1)];
2298
+ });
2299
+
2300
+ const polyline = pts.map(p => p.join(',')).join(' ');
2301
+ const areaPath = `M${pts[0][0]},${H} ` +
2302
+ pts.map(p => `L${p[0]},${p[1]}`).join(' ') +
2303
+ ` L${pts[pts.length-1][0]},${H} Z`;
2304
+
2305
+ el.innerHTML = `
2306
+ <svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
2307
+ <defs>
2308
+ <linearGradient id="durGrad" x1="0" y1="0" x2="0" y2="1">
2309
+ <stop offset="0%" stop-color="var(--accent)" stop-opacity=".25"/>
2310
+ <stop offset="100%" stop-color="var(--accent)" stop-opacity="0"/>
2311
+ </linearGradient>
2312
+ </defs>
2313
+ <path d="${areaPath}" fill="url(#durGrad)"/>
2314
+ <polyline points="${polyline}" fill="none" stroke="var(--accent)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>
2315
+ ${pts.map((p, i) => `<circle cx="${p[0]}" cy="${p[1]}" r="2.5" fill="var(--accent)" opacity=".8">
2316
+ <title>${fmtDur(display[i].duration)} · ${fmtDate(display[i].timestamp)}</title>
2317
+ </circle>`).join('')}
2318
+ </svg>`;
2319
+ }
2320
+
2321
+ // ── Tab switch ─────────────────────────────────────────────────────────────
2322
+ window.switchTab = function(view, btn) {
2323
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
2324
+ btn.classList.add('active');
2325
+ document.getElementById('overallView').style.display = view === 'overall' ? 'block' : 'none';
2326
+ const tv = document.getElementById('testView');
2327
+ tv.style.display = view === 'test' ? 'block' : 'none';
2328
+ if (view === 'test') tv.classList.add('active');
2329
+ };
2330
+
2331
+ // ── Test index & grid ──────────────────────────────────────────────────────
2332
+ function buildTestIndex() {
2333
+ const seen = new Map();
2334
+ runs.forEach(run => {
2335
+ (run.allTests ?? []).forEach(t => {
2336
+ const key = `${t.project}::${t.title}`;
2337
+ if (!seen.has(key)) seen.set(key, { title: t.title, project: t.project, tags: t.tags ?? [], key });
2338
+ });
2339
+ });
2340
+ allTests = [...seen.values()].sort((a, b) =>
2341
+ a.project !== b.project ? a.project.localeCompare(b.project) : a.title.localeCompare(b.title)
2342
+ );
2343
+ }
2344
+
2345
+ function getSparkline(testKey) {
2346
+ // Last 10 runs, oldest first
2347
+ const [project, ...rest] = testKey.split('::');
2348
+ const title = rest.join('::');
2349
+ return runs.slice(0, 10).reverse().map(run => {
2350
+ const t = (run.allTests ?? []).find(x => x.project === project && x.title === title);
2351
+ return t ? statusClass(t.status) : 'none';
2352
+ });
2353
+ }
2354
+
2355
+ function renderTestGrid(tests) {
2356
+ const grid = document.getElementById('testGrid');
2357
+ if (!grid) return;
2358
+
2359
+ if (!tests.length) {
2360
+ grid.innerHTML = '<div style="color:var(--muted);font-size:13px;padding:16px">No tests found.</div>';
2361
+ return;
2362
+ }
2363
+
2364
+ grid.innerHTML = tests.map(t => {
2365
+ const spark = getSparkline(t.key);
2366
+ const tagsHtml = t.tags.length
2367
+ ? `<div class="test-tags">${t.tags.map(g => `<span class="tag">${esc(g)}</span>`).join('')}</div>`
2368
+ : '';
2369
+ return `
2370
+ <div class="test-card ${selectedTestKey === t.key ? 'selected' : ''}"
2371
+ data-key="${esc(t.key)}">
2372
+ <div class="test-card-name">${esc(t.title)}</div>
2373
+ <div class="test-card-meta">
2374
+ <span class="test-card-project">${esc(t.project)}</span>
2375
+ <div class="sparkline" title="Last 10 runs (oldest → newest)">
2376
+ ${spark.map(s => `<span class="spark-dot ${s}"></span>`).join('')}
2377
+ </div>
2378
+ </div>
2379
+ ${tagsHtml}
2380
+ </div>`;
2381
+ }).join('');
2382
+
2383
+ // Re-attach delegation each render (grid is re-created each time)
2384
+ grid.onclick = function(e) {
2385
+ const card = e.target.closest('.test-card');
2386
+ if (!card) return;
2387
+ const key = card.dataset.key;
2388
+ if (key) selectTest(key);
2389
+ };
2390
+ }
2391
+
2392
+ window.filterTests = function() {
2393
+ const q = document.getElementById('testSearch').value.toLowerCase();
2394
+ const filtered = allTests.filter(t =>
2395
+ t.title.toLowerCase().includes(q) ||
2396
+ t.project.toLowerCase().includes(q) ||
2397
+ t.tags.some(g => g.toLowerCase().includes(q))
2398
+ );
2399
+ renderTestGrid(filtered);
2400
+ };
2401
+
2402
+ function selectTest(key) {
2403
+ selectedTestKey = key;
2404
+ const hint = document.getElementById('testSelectHint');
2405
+ if (hint) hint.style.display = 'none';
2406
+ const q = document.getElementById('testSearch')?.value?.toLowerCase() ?? '';
2407
+ const filtered = q
2408
+ ? allTests.filter(t => t.title.toLowerCase().includes(q) || t.project.toLowerCase().includes(q) || t.tags.some(g => g.toLowerCase().includes(q)))
2409
+ : allTests;
2410
+ renderTestGrid(filtered);
2411
+ const [project, ...rest] = key.split('::');
2412
+ const title = rest.join('::');
2413
+ renderTestDetail(project, title);
2414
+ }
2415
+ window.selectTest = selectTest;
2416
+
2417
+ function renderTestDetail(project, title) {
2418
+ const detail = document.getElementById('testDetail');
2419
+ if (!detail) return;
2420
+
2421
+ // Collect history oldest-first for charts, newest-first for the run list
2422
+ const historyAsc = runs.slice().reverse().map(run => {
2423
+ const t = (run.allTests ?? []).find(x => x.project === project && x.title === title);
2424
+ return t ? { ts: run.timestamp, runId: run.runId, ...t } : null;
2425
+ }).filter(Boolean);
2426
+
2427
+ const historyDesc = [...historyAsc].reverse();
2428
+
2429
+ const passed = historyAsc.filter(h => h.status === 'passed').length;
2430
+ const failed = historyAsc.filter(h => h.status === 'failed').length;
2431
+ const flaky = historyAsc.filter(h => h.status === 'flaky').length;
2432
+ const rate = historyAsc.length ? (passed / historyAsc.length * 100).toFixed(1) : 0;
2433
+
2434
+ // Pull annotations from the most recent run that has them
2435
+ const annotations = historyDesc.find(h => h.annotations?.length)?.annotations ?? [];
2436
+ const annotTitle = annotations.find(a => a.type === 'title' || a.type === 'description')?.description ?? null;
2437
+ const annotDesc = annotations.find(a => a.type === 'description')?.description ?? null;
2438
+ // All non-title/description annotations shown as metadata chips
2439
+ const metaAnnots = annotations.filter(a => a.type !== 'title' && a.type !== 'description');
2440
+
2441
+ detail.innerHTML = `
2442
+ <div class="test-detail">
2443
+
2444
+ <!-- Header: name + summary stats -->
2445
+ <div class="test-detail-header">
2446
+ <div>
2447
+ <div class="test-detail-name">${esc(title)}</div>
2448
+ ${metaAnnots.length ? `<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">
2449
+ ${metaAnnots.map(a => `<span class="tag" style="background:rgba(124,159,255,.12);color:var(--accent2);border-color:rgba(124,159,255,.2)">${esc(a.type)}${a.description ? ': ' + esc(a.description) : ''}</span>`).join('')}
2450
+ </div>` : ''}
2451
+ </div>
2452
+ <div class="test-detail-stats">
2453
+ <div class="test-detail-stat">
2454
+ <div class="test-detail-stat-value" style="color:var(--pass)">${passed}</div>
2455
+ <div class="test-detail-stat-label">passed</div>
2456
+ </div>
2457
+ ${failed > 0 ? `<div class="test-detail-stat">
2458
+ <div class="test-detail-stat-value" style="color:var(--fail)">${failed}</div>
2459
+ <div class="test-detail-stat-label">failed</div>
2460
+ </div>` : ''}
2461
+ ${flaky > 0 ? `<div class="test-detail-stat">
2462
+ <div class="test-detail-stat-value" style="color:var(--flaky)">${flaky}</div>
2463
+ <div class="test-detail-stat-label">flaky</div>
2464
+ </div>` : ''}
2465
+ <div class="test-detail-stat">
2466
+ <div class="test-detail-stat-value" style="color:${rate>=80?'var(--pass)':rate>=50?'var(--flaky)':'var(--fail)'}">${rate}<span style="font-size:14px">%</span></div>
2467
+ <div class="test-detail-stat-label">pass rate</div>
2468
+ </div>
2469
+ </div>
2470
+ </div>
2471
+
2472
+ <!-- Annotation description block (if any) -->
2473
+ ${annotDesc ? `
2474
+ <div class="test-annot-block">
2475
+ <div class="test-annot-type">description</div>
2476
+ <div class="test-annot-text">${esc(annotDesc)}</div>
2477
+ </div>` : ''}
2478
+
2479
+ <!-- Sub-panels: duration chart + failure reasons -->
2480
+ <div class="detail-panels">
2481
+ ${buildDurationPanel(historyAsc)}
2482
+ ${buildFailureReasonsPanel(historyAsc)}
2483
+ </div>
2484
+
2485
+ <!-- Per-run history list -->
2486
+ ${historyDesc.map((h, i) => renderTrendRow(h, i)).join('')}
2487
+ </div>`;
2488
+
2489
+ detail.scrollIntoView({ behavior: 'smooth', block: 'start' });
2490
+ }
2491
+
2492
+ function renderTrendRow(h, i) {
2493
+ const hasError = !!(h.error);
2494
+ const annotTitle = h.annotations?.find(a => a.type === 'title')?.description ?? null;
2495
+ const hasDetail = hasError || annotTitle;
2496
+ const rowId = `tr-${i}-${Date.now()}`;
2497
+
2498
+ return `
2499
+ <div class="trend-row ${statusClass(h.status)}${hasDetail ? ' has-detail' : ''}" id="${rowId}">
2500
+ <div class="trend-row-summary" ${hasDetail ? `onclick="toggleTrendRow('${rowId}')"` : ''}>
2501
+ <div class="trend-row-left">
2502
+ <div class="trend-row-date">${fmtDate(h.ts)}</div>
2503
+ <div class="trend-row-runid">${esc(h.runId)}</div>
2504
+ </div>
2505
+ <div class="trend-row-right">
2506
+ <div class="trend-row-dur">${fmtDur(h.duration)}</div>
2507
+ <div class="trend-row-status" style="color:${statusColor(h.status)}">${h.status}</div>
2508
+ ${hasDetail ? `<span class="trend-row-chevron" id="chev-${rowId}">▼</span>` : '<span style="width:14px"></span>'}
2509
+ </div>
2510
+ </div>
2511
+ ${hasDetail ? `
2512
+ <div class="trend-row-body" id="body-${rowId}">
2513
+ ${annotTitle ? `
2514
+ <div class="annot-title">
2515
+ <span>⚑</span> ${esc(annotTitle)}
2516
+ </div>` : ''}
2517
+ ${hasError ? `
2518
+ <div class="error-toggle" onclick="toggleError('err-${rowId}', 'etoggle-${rowId}')">
2519
+ <span class="error-toggle-icon" id="etoggle-${rowId}">▶</span>
2520
+ playwright error
2521
+ </div>
2522
+ <div class="trend-row-error" id="err-${rowId}">${esc(h.error)}</div>` : ''}
2523
+ </div>` : ''}
2524
+ </div>`;
2525
+ }
2526
+
2527
+ window.toggleTrendRow = function(rowId) {
2528
+ const body = document.getElementById(`body-${rowId}`);
2529
+ const chev = document.getElementById(`chev-${rowId}`);
2530
+ if (!body) return;
2531
+ const open = body.classList.toggle('open');
2532
+ if (chev) chev.classList.toggle('open', open);
2533
+ };
2534
+
2535
+ window.toggleError = function(errId, toggleId) {
2536
+ const err = document.getElementById(errId);
2537
+ const toggle = document.getElementById(toggleId);
2538
+ if (!err) return;
2539
+ const open = err.classList.toggle('open');
2540
+ if (toggle) toggle.classList.toggle('open', open);
2541
+ };
2542
+
2543
+ // ── Duration mini-chart ────────────────────────────────────────────────────
2544
+ function buildDurationPanel(historyAsc) {
2545
+ if (historyAsc.length < 2) {
2546
+ return `<div class="detail-sub-panel">
2547
+ <div class="detail-sub-title">Duration over time</div>
2548
+ <div class="no-failures-msg">Not enough data yet</div>
2549
+ </div>`;
2550
+ }
2551
+
2552
+ const durations = historyAsc.map(h => h.duration);
2553
+ const minD = Math.min(...durations);
2554
+ const maxD = Math.max(...durations);
2555
+ const range = maxD - minD || 1;
2556
+ const W = 260, H = 60;
2557
+
2558
+ const pts = historyAsc.map((h, i) => {
2559
+ const x = (i / (historyAsc.length - 1)) * W;
2560
+ const y = H - ((h.duration - minD) / range) * (H - 8) - 4;
2561
+ return [x.toFixed(1), y.toFixed(1), h];
2562
+ });
2563
+
2564
+ const polyline = pts.map(p => `${p[0]},${p[1]}`).join(' ');
2565
+ const areaPath = `M${pts[0][0]},${H} ` +
2566
+ pts.map(p => `L${p[0]},${p[1]}`).join(' ') +
2567
+ ` L${pts[pts.length-1][0]},${H} Z`;
2568
+
2569
+ // Colour the line by whether the test was passing or failing each run
2570
+ // Segment approach: draw individual coloured dots per point
2571
+ const dots = pts.map(([x, y, h]) => {
2572
+ const c = h.status === 'passed' ? 'var(--pass)'
2573
+ : h.status === 'flaky' ? 'var(--flaky)'
2574
+ : 'var(--fail)';
2575
+ return `<circle cx="${x}" cy="${y}" r="3" fill="${c}">
2576
+ <title>${fmtDur(h.duration)} · ${h.status} · ${fmtDate(h.ts)}</title>
2577
+ </circle>`;
2578
+ }).join('');
2579
+
2580
+ // Delta: compare last run to previous run
2581
+ const last = durations[durations.length - 1];
2582
+ const prev = durations[durations.length - 2];
2583
+ const deltaPct = ((last - prev) / prev * 100);
2584
+ const deltaSign = deltaPct > 5 ? 'up' : deltaPct < -5 ? 'down' : 'flat';
2585
+ const deltaLabel = deltaSign === 'flat'
2586
+ ? '~ stable'
2587
+ : `${deltaPct > 0 ? '+' : ''}${deltaPct.toFixed(0)}% vs prev`;
2588
+
2589
+ return `
2590
+ <div class="detail-sub-panel">
2591
+ <div class="detail-sub-title">
2592
+ Duration over time
2593
+ <span class="dur-delta ${deltaSign}">${deltaLabel}</span>
2594
+ </div>
2595
+ <div class="dur-mini">
2596
+ <svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
2597
+ <defs>
2598
+ <linearGradient id="tdGrad" x1="0" y1="0" x2="0" y2="1">
2599
+ <stop offset="0%" stop-color="var(--accent)" stop-opacity=".18"/>
2600
+ <stop offset="100%" stop-color="var(--accent)" stop-opacity="0"/>
2601
+ </linearGradient>
2602
+ </defs>
2603
+ <path d="${areaPath}" fill="url(#tdGrad)"/>
2604
+ <polyline points="${polyline}" fill="none" stroke="var(--accent)" stroke-width="1.5"
2605
+ stroke-linejoin="round" stroke-linecap="round" stroke-opacity=".5"/>
2606
+ ${dots}
2607
+ </svg>
2608
+ </div>
2609
+ <div class="dur-mini-meta">
2610
+ <span>min ${fmtDur(minD)}</span>
2611
+ <span>avg ${fmtDur(Math.round(durations.reduce((a,b)=>a+b,0)/durations.length))}</span>
2612
+ <span>max ${fmtDur(maxD)}</span>
2613
+ </div>
2614
+ </div>`;
2615
+ }
2616
+
2617
+ // ── Failure reasons panel ──────────────────────────────────────────────────
2618
+ function buildFailureReasonsPanel(historyAsc) {
2619
+ const failures = historyAsc.filter(h => h.status === 'failed' || h.status === 'flaky');
2620
+
2621
+ if (!failures.length) {
2622
+ return `<div class="detail-sub-panel">
2623
+ <div class="detail-sub-title">Failure reasons</div>
2624
+ <div class="no-failures-msg" style="color:var(--pass)">✓ No failures recorded</div>
2625
+ </div>`;
2626
+ }
2627
+
2628
+ // Group errors by normalised message — strip line numbers / file paths
2629
+ // so the same logical error groups together even if the stack changes slightly
2630
+ const counts = new Map();
2631
+ for (const h of failures) {
2632
+ const raw = h.error ?? '(no error message)';
2633
+ // Normalise: first line only, collapse numbers, trim path prefixes
2634
+ const key = raw
2635
+ .split('\n')[0]
2636
+ .replace(/\d+/g, 'N') // collapse all numbers → N
2637
+ .replace(/'.+?'/g, "'…'") // collapse quoted strings
2638
+ .replace(/".+?"/g, '"…"')
2639
+ .trim()
2640
+ .substring(0, 80);
2641
+ const prev = counts.get(key) ?? { count: 0, isFlaky: false };
2642
+ counts.set(key, {
2643
+ count: prev.count + 1,
2644
+ isFlaky: prev.isFlaky || h.status === 'flaky',
2645
+ latest: h.ts,
2646
+ });
2647
+ }
2648
+
2649
+ // Sort by frequency desc
2650
+ const sorted = [...counts.entries()].sort((a, b) => b[1].count - a[1].count);
2651
+ const maxCount = sorted[0][1].count;
2652
+
2653
+ const rows = sorted.slice(0, 6).map(([label, { count, isFlaky }]) => {
2654
+ const pct = (count / maxCount * 100).toFixed(0);
2655
+ const times = count === 1 ? '1 time' : `${count}×`;
2656
+ return `
2657
+ <div class="reason-row">
2658
+ <div class="reason-row-header">
2659
+ <span class="reason-label" title="${esc(label)}">${esc(label)}</span>
2660
+ <span class="reason-count">${times}</span>
2661
+ </div>
2662
+ <div class="reason-bar-track">
2663
+ <div class="reason-bar-fill ${isFlaky ? 'flaky' : ''}" style="width:${pct}%"></div>
2664
+ </div>
2665
+ </div>`;
2666
+ }).join('');
2667
+
2668
+ const more = sorted.length > 6
2669
+ ? `<div class="reason-count" style="margin-top:4px">+${sorted.length - 6} more distinct errors</div>`
2670
+ : '';
2671
+
2672
+ return `
2673
+ <div class="detail-sub-panel">
2674
+ <div class="detail-sub-title">
2675
+ Failure reasons
2676
+ <span>${failures.length} failure${failures.length > 1 ? 's' : ''} total</span>
2677
+ </div>
2678
+ <div class="reason-list">${rows}</div>
2679
+ ${more}
2680
+ </div>`;
2681
+ }
2682
+
2683
+ // ── Latest run filter ──────────────────────────────────────────────────────
2684
+ window.filterLatestRun = function(status) {
2685
+ activeFilter = status;
2686
+ const latest = runs[0];
2687
+ const latestRate = latest.totalTests > 0 ? (latest.passed / latest.totalTests * 100).toFixed(1) : '0.0';
2688
+ const totalRuns = runs.length;
2689
+ const avgPass = runs.reduce((s, r) => s + (r.totalTests > 0 ? r.passed / r.totalTests * 100 : 0), 0) / runs.length;
2690
+ renderMainContent(latest, totalRuns, avgPass, latestRate);
2691
+ };
2692
+
2693
+ window.clearFilter = function() {
2694
+ activeFilter = null;
2695
+ const latest = runs[0];
2696
+ const latestRate = latest.totalTests > 0 ? (latest.passed / latest.totalTests * 100).toFixed(1) : '0.0';
2697
+ const totalRuns = runs.length;
2698
+ const avgPass = runs.reduce((s, r) => s + (r.totalTests > 0 ? r.passed / r.totalTests * 100 : 0), 0) / runs.length;
2699
+ renderMainContent(latest, totalRuns, avgPass, latestRate);
2700
+ };
2701
+
2702
+ // ── Runs list ──────────────────────────────────────────────────────────────
2703
+ function renderRunsList() {
2704
+ const el = document.getElementById('runsList');
2705
+
2706
+ el.innerHTML = runs.map((run, idx) => {
2707
+ const allT = run.allTests ?? [];
2708
+ const isPass = run.failed === 0;
2709
+ const p75 = computeP75(allT.map(t => t.duration));
2710
+
2711
+ const isLatest = idx === 0;
2712
+ const filteredT = (isLatest && activeFilter) ? allT.filter(t => t.status === activeFilter) : allT;
2713
+ const notable = filteredT.filter(t => t.status === 'failed' || t.status === 'flaky');
2714
+ const displayTests = (isLatest && activeFilter) ? filteredT : notable;
2715
+ const hasMore = !activeFilter && allT.length > notable.length;
2716
+ const isFiltered = isLatest && activeFilter;
2717
+
2718
+ return `
2719
+ <div class="run-card" id="run-${idx}">
2720
+ <div class="run-card-header" onclick="toggleRun(${idx})">
2721
+ <div class="run-card-left">
2722
+ <span class="run-status-dot" style="background:${isPass ? 'var(--pass)' : 'var(--fail)'}"></span>
2723
+ <span class="run-card-ts">${fmtDate(run.timestamp)}</span>
2724
+ <div class="run-badges">
2725
+ <span class="badge pass">${run.passed} passed</span>
2726
+ ${run.failed > 0 ? `<span class="badge fail">${run.failed} failed</span>` : ''}
2727
+ ${run.flaky > 0 ? `<span class="badge flaky">${run.flaky} flaky</span>` : ''}
2728
+ ${run.skipped > 0 ? `<span class="badge skip">${run.skipped} skipped</span>` : ''}
2729
+ </div>
2730
+ </div>
2731
+ <div class="run-card-right">
2732
+ <span class="run-dur">${fmtDur(run.duration)}</span>
2733
+ ${allT.length > 0 ? `<span class="run-chevron" id="chev-${idx}">▼</span>` : ''}
2734
+ </div>
2735
+ </div>
2736
+ ${allT.length > 0 ? `
2737
+ <div class="run-detail ${isLatest && activeFilter ? 'open' : ''}" id="detail-${idx}">
2738
+ ${isFiltered ? `
2739
+ <div class="filter-banner">
2740
+ <span class="filter-banner-label">Showing <strong>${filteredT.length}</strong> ${activeFilter} test${filteredT.length !== 1 ? 's' : ''} from this run</span>
2741
+ <button class="filter-banner-clear" onclick="clearFilter()">✕ Show all</button>
2742
+ </div>` :
2743
+ notable.length > 0 ? `
2744
+ <div class="run-detail-toggle">
2745
+ <span class="detail-label">Failed &amp; flaky (${notable.length})</span>
2746
+ ${hasMore ? `<button class="show-all-btn" id="showall-${idx}" onclick="showAllTests(${idx})">show all ${allT.length} tests</button>` : ''}
2747
+ </div>` :
2748
+ `<div class="run-detail-toggle">
2749
+ <span class="detail-label">All tests (${allT.length})</span>
2750
+ </div>`
2751
+ }
2752
+ <div id="tests-${idx}">
2753
+ ${displayTests.length ? renderTestRows(displayTests, p75) : `<div style="font-family:var(--mono);font-size:12px;color:var(--muted);padding:8px 0">No ${activeFilter} tests in this run.</div>`}
2754
+ </div>
2755
+ </div>` : ''}
2756
+ </div>`;
2757
+ }).join('');
2758
+
2759
+ if (activeFilter) {
2760
+ const chev = document.getElementById('chev-0');
2761
+ if (chev) chev.classList.add('open');
2762
+ }
2763
+ }
2764
+
2765
+ function renderTestRows(tests, p75 = 0) {
2766
+ return tests.map((t, i) => {
2767
+ const errId = `tre-${i}-${Math.random().toString(36).slice(2,7)}`;
2768
+ const slow = p75 > 0 && isSlowTest(t, p75);
2769
+ return `
2770
+ <div class="test-row ${statusClass(t.status)}">
2771
+ <div class="test-row-body">
2772
+ <div class="test-row-title" style="display:flex;align-items:center;gap:6px">
2773
+ <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.title)}</span>
2774
+ ${slow ? `<span class="slow-badge">SLOW</span>` : ''}
2775
+ </div>
2776
+ <div class="test-row-meta">
2777
+ <span>${esc(t.file)}</span>
2778
+ <span>${esc(t.project)}</span>
2779
+ <span>${fmtDur(t.duration)}</span>
2780
+ </div>
2781
+ ${t.error ? `
2782
+ <button class="test-row-error-toggle" onclick="toggleRowError('${errId}')">
2783
+ <span class="test-row-error-icon" id="icon-${errId}">▶</span>
2784
+ error
2785
+ </button>
2786
+ <div class="test-row-error" id="${errId}">${esc(t.error)}</div>` : ''}
2787
+ ${(t.artifacts?.trace || t.artifacts?.screenshot) ? `
2788
+ <div class="artifact-links">
2789
+ ${t.artifacts.trace ? `<a href="${esc(t.artifacts.trace)}" download class="artifact-link">↓ trace</a>` : ''}
2790
+ ${t.artifacts.screenshot ? `<a href="${esc(t.artifacts.screenshot)}" download class="artifact-link">↓ screenshot</a>` : ''}
2791
+ </div>` : ''}
2792
+ </div>
2793
+ </div>`;
2794
+ }).join('');
2795
+ }
2796
+
2797
+ window.toggleRowError = function(errId) {
2798
+ const el = document.getElementById(errId);
2799
+ const icon = document.getElementById(`icon-${errId}`);
2800
+ if (!el) return;
2801
+ const open = el.classList.toggle('open');
2802
+ if (icon) icon.classList.toggle('open', open);
2803
+ };
2804
+
2805
+ window.toggleRun = function(idx) {
2806
+ const detail = document.getElementById(`detail-${idx}`);
2807
+ const chev = document.getElementById(`chev-${idx}`);
2808
+ if (!detail) return;
2809
+ const open = detail.classList.toggle('open');
2810
+ if (chev) chev.classList.toggle('open', open);
2811
+ };
2812
+
2813
+ window.showAllTests = function(idx) {
2814
+ const btn = document.getElementById(`showall-${idx}`);
2815
+ const container = document.getElementById(`tests-${idx}`);
2816
+ if (!btn || !container) return;
2817
+ container.innerHTML = renderTestRows(runs[idx].allTests ?? []);
2818
+ btn.remove();
2819
+ };
2820
+
2821
+ // ── Go ─────────────────────────────────────────────────────────────────────
2822
+ init();
2823
+ </script>
2824
+ </body>
2825
+ </html>