plum-e2e 1.2.4 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CLAUDE.md +201 -0
  2. package/README.md +245 -90
  3. package/backend/_scaffold/utils/browser.ts +5 -2
  4. package/backend/app.js +9 -1
  5. package/backend/config/scripts/generate-report.js +34 -73
  6. package/backend/config/scripts/run-tests.js +13 -3
  7. package/backend/constants/triggers.js +67 -0
  8. package/backend/lib/reportFilename.js +37 -0
  9. package/backend/lib/testChunker.js +73 -0
  10. package/backend/middleware/auth.js +32 -0
  11. package/backend/package.json +4 -2
  12. package/backend/prisma/migrations/20260616000000_add_runners_and_browser/migration.sql +26 -0
  13. package/backend/prisma/migrations/20260616000001_cron_runner_ids/migration.sql +6 -0
  14. package/backend/prisma/migrations/20260617000000_cron_enabled/migration.sql +1 -0
  15. package/backend/prisma/migrations/20260617000001_report_content/migration.sql +8 -0
  16. package/backend/prisma/schema.prisma +21 -1
  17. package/backend/routes/cron.routes.js +28 -0
  18. package/backend/routes/node.routes.js +121 -0
  19. package/backend/routes/reports.routes.js +23 -20
  20. package/backend/routes/runners.routes.js +83 -0
  21. package/backend/scripts/add-local-runner.js +120 -0
  22. package/backend/scripts/create-test.js +148 -0
  23. package/backend/server.js +16 -7
  24. package/backend/services/backupService.js +3 -30
  25. package/backend/services/cronService.js +220 -36
  26. package/backend/services/reportService.js +227 -55
  27. package/backend/services/runnerService.js +179 -0
  28. package/backend/websockets/socketHandler.js +162 -21
  29. package/bin/plum.js +160 -31
  30. package/docker-compose.node.yml +59 -0
  31. package/docker-compose.yml +2 -0
  32. package/frontend/package.json +1 -4
  33. package/frontend/src/app.css +20 -254
  34. package/frontend/src/app.html +1 -1
  35. package/frontend/src/lib/api/reports.js +17 -36
  36. package/frontend/src/lib/api/runners.js +61 -0
  37. package/frontend/src/lib/api/schedules.js +34 -5
  38. package/frontend/src/lib/api/settings.js +5 -5
  39. package/frontend/src/lib/api/tests.js +2 -19
  40. package/frontend/src/lib/components/icons/BrowserIcon.svelte +75 -0
  41. package/frontend/src/lib/components/layout/Nav.svelte +42 -47
  42. package/frontend/src/lib/components/layout/RunnerPanel.svelte +913 -253
  43. package/frontend/src/lib/components/ui/Badge.svelte +6 -1
  44. package/frontend/src/lib/components/ui/ConfirmModal.svelte +98 -0
  45. package/frontend/{tailwind.config.js → src/lib/components/ui/EmptyState.svelte} +27 -8
  46. package/frontend/{postcss.config.js → src/lib/components/ui/Toast.svelte} +20 -7
  47. package/frontend/src/lib/constants.js +36 -0
  48. package/frontend/src/lib/stores/runner.js +23 -12
  49. package/frontend/src/lib/styles/global.css +176 -0
  50. package/frontend/src/lib/styles/reset.css +86 -0
  51. package/frontend/src/lib/styles/tokens.css +90 -0
  52. package/frontend/src/lib/utils/format.js +46 -0
  53. package/frontend/src/routes/+page.svelte +16 -35
  54. package/frontend/src/routes/reports/+page.svelte +84 -167
  55. package/frontend/src/routes/reports/{[slug] → [id]}/+page.svelte +325 -76
  56. package/frontend/src/routes/reports/live/+page.svelte +704 -0
  57. package/frontend/src/routes/scheduled-tests/+page.svelte +328 -88
  58. package/frontend/src/routes/settings/+page.svelte +774 -127
  59. package/frontend/static/favicon-32x32.png +0 -0
  60. package/frontend/static/favicon.ico +0 -0
  61. package/package.json +1 -1
  62. package/frontend/static/favicon.png +0 -0
@@ -0,0 +1,704 @@
1
+ <!--
2
+ * This file is part of Plum.
3
+ *
4
+ * Plum is free software: you can redistribute it and/or modify
5
+ * it under the terms of the GNU General Public License as published by
6
+ * the Free Software Foundation, either version 3 of the License, or
7
+ * (at your option) any later version.
8
+ *
9
+ * Plum is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU General Public License
15
+ * along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ -->
17
+
18
+ <script>
19
+ import { onMount, afterUpdate, onDestroy } from 'svelte';
20
+ import { goto } from '$app/navigation';
21
+ import { fly, fade } from 'svelte/transition';
22
+ import { runnerState, cancelRun } from '$lib/stores/runner';
23
+ import { reportUrl } from '$lib/api/reports';
24
+
25
+ let terminalEl;
26
+ let laneEls = {};
27
+ let redirectTimer = null;
28
+ let redirectCountdown = 3;
29
+ let countdownInterval = null;
30
+
31
+ $: state = $runnerState;
32
+ $: isMulti = state.lanes.length > 1;
33
+
34
+ // Auto-scroll terminals
35
+ afterUpdate(() => {
36
+ if (terminalEl) terminalEl.scrollTop = terminalEl.scrollHeight;
37
+ for (const el of Object.values(laneEls)) {
38
+ if (el) el.scrollTop = el.scrollHeight;
39
+ }
40
+ });
41
+
42
+ // Watch for test completion → auto-navigate to report
43
+ $: if (state.testCompleted && state.latestReportId && !redirectTimer) {
44
+ redirectCountdown = 3;
45
+ countdownInterval = setInterval(() => {
46
+ redirectCountdown--;
47
+ if (redirectCountdown <= 0) {
48
+ clearInterval(countdownInterval);
49
+ }
50
+ }, 1000);
51
+ redirectTimer = setTimeout(() => {
52
+ goto(reportUrl(state.latestReportId));
53
+ }, 3000);
54
+ }
55
+
56
+ onDestroy(() => {
57
+ if (redirectTimer) clearTimeout(redirectTimer);
58
+ if (countdownInterval) clearInterval(countdownInterval);
59
+ });
60
+
61
+ function goNow() {
62
+ if (redirectTimer) clearTimeout(redirectTimer);
63
+ if (countdownInterval) clearInterval(countdownInterval);
64
+ goto(reportUrl(state.latestReportId));
65
+ }
66
+
67
+ function handleCancel() {
68
+ cancelRun();
69
+ }
70
+
71
+ function laneStatusColor(status) {
72
+ if (status === 'done') return 'var(--pass)';
73
+ if (status === 'error') return 'var(--fail)';
74
+ return 'var(--accent)';
75
+ }
76
+ </script>
77
+
78
+ <svelte:head><title>Live Run — Plum</title></svelte:head>
79
+
80
+ <div class="back-row">
81
+ <a href="/reports" class="back-link">
82
+ <svg
83
+ width="14"
84
+ height="14"
85
+ viewBox="0 0 24 24"
86
+ fill="none"
87
+ stroke="currentColor"
88
+ stroke-width="2"
89
+ stroke-linecap="round"
90
+ >
91
+ <line x1="19" y1="12" x2="5" y2="12" />
92
+ <polyline points="12 19 5 12 12 5" />
93
+ </svg>
94
+ Reports
95
+ </a>
96
+ </div>
97
+
98
+ {#if !state.running && !state.testCompleted}
99
+ <!-- Nothing running and no completed test -->
100
+ <div class="idle-state">
101
+ <div class="idle-icon">
102
+ <svg
103
+ width="40"
104
+ height="40"
105
+ viewBox="0 0 24 24"
106
+ fill="none"
107
+ stroke="currentColor"
108
+ stroke-width="1.5"
109
+ stroke-linecap="round"
110
+ stroke-linejoin="round"
111
+ >
112
+ <circle cx="12" cy="12" r="10" />
113
+ <polyline points="12 6 12 12 16 14" />
114
+ </svg>
115
+ </div>
116
+ <h2>No tests currently running</h2>
117
+ <p>Start a test from the panel below, then come back here to watch it live.</p>
118
+ <a href="/reports" class="idle-link">View past reports →</a>
119
+ </div>
120
+ {:else}
121
+ <!-- Run header -->
122
+ <div
123
+ class="run-header"
124
+ class:header-pass={state.status === 'pass'}
125
+ class:header-fail={state.status === 'fail'}
126
+ >
127
+ <div class="header-left">
128
+ {#if state.running}
129
+ <span class="live-badge">
130
+ <span class="live-dot"></span>
131
+ Live
132
+ </span>
133
+ {:else if state.status === 'pass'}
134
+ <span class="result-badge pass-badge">
135
+ <svg
136
+ width="12"
137
+ height="12"
138
+ viewBox="0 0 24 24"
139
+ fill="none"
140
+ stroke="currentColor"
141
+ stroke-width="2.5"
142
+ stroke-linecap="round"
143
+ stroke-linejoin="round"
144
+ >
145
+ <polyline points="20 6 9 17 4 12" />
146
+ </svg>
147
+ Passed
148
+ </span>
149
+ {:else}
150
+ <span class="result-badge fail-badge">
151
+ <svg
152
+ width="12"
153
+ height="12"
154
+ viewBox="0 0 24 24"
155
+ fill="none"
156
+ stroke="currentColor"
157
+ stroke-width="2.5"
158
+ stroke-linecap="round"
159
+ stroke-linejoin="round"
160
+ >
161
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
162
+ </svg>
163
+ Failed
164
+ </span>
165
+ {/if}
166
+
167
+ {#if state.currentRun}
168
+ <div class="run-info">
169
+ <span class="run-tag-label">{state.currentRun.tag || 'all tests'}</span>
170
+ <span class="run-sep">·</span>
171
+ <span class="run-detail"
172
+ >{state.currentRun.workers} worker{state.currentRun.workers !== 1 ? 's' : ''}</span
173
+ >
174
+ <span class="run-sep">·</span>
175
+ <span class="run-detail">{state.currentRun.browser}</span>
176
+ {#if isMulti}
177
+ <span class="run-sep">·</span>
178
+ <span class="run-detail">{state.lanes.length} runners</span>
179
+ {/if}
180
+ </div>
181
+ {/if}
182
+ </div>
183
+
184
+ {#if state.running}
185
+ <button class="cancel-btn" on:click={handleCancel}>
186
+ <svg
187
+ width="12"
188
+ height="12"
189
+ viewBox="0 0 24 24"
190
+ fill="none"
191
+ stroke="currentColor"
192
+ stroke-width="2"
193
+ stroke-linecap="round"
194
+ stroke-linejoin="round"
195
+ >
196
+ <rect x="3" y="3" width="18" height="18" rx="2" />
197
+ <line x1="9" y1="9" x2="15" y2="15" />
198
+ <line x1="15" y1="9" x2="9" y2="15" />
199
+ </svg>
200
+ Cancel run
201
+ </button>
202
+ {/if}
203
+ </div>
204
+
205
+ <!-- Completion overlay -->
206
+ {#if state.testCompleted && state.latestReportId}
207
+ <div
208
+ class="completion-bar"
209
+ class:pass-bar={state.status === 'pass'}
210
+ class:fail-bar={state.status === 'fail'}
211
+ transition:fly={{ y: -8, duration: 250 }}
212
+ >
213
+ <div class="completion-left">
214
+ {#if state.status === 'pass'}
215
+ <svg
216
+ width="16"
217
+ height="16"
218
+ viewBox="0 0 24 24"
219
+ fill="none"
220
+ stroke="currentColor"
221
+ stroke-width="2.5"
222
+ stroke-linecap="round"
223
+ stroke-linejoin="round"
224
+ >
225
+ <polyline points="20 6 9 17 4 12" />
226
+ </svg>
227
+ All tests passed
228
+ {:else}
229
+ <svg
230
+ width="16"
231
+ height="16"
232
+ viewBox="0 0 24 24"
233
+ fill="none"
234
+ stroke="currentColor"
235
+ stroke-width="2.5"
236
+ stroke-linecap="round"
237
+ stroke-linejoin="round"
238
+ >
239
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
240
+ </svg>
241
+ Some tests failed
242
+ {/if}
243
+ </div>
244
+ <div class="completion-right">
245
+ <span class="redirect-hint">Redirecting in {redirectCountdown}s…</span>
246
+ <button class="view-now-btn" on:click={goNow}>
247
+ View Report Now
248
+ <svg
249
+ width="12"
250
+ height="12"
251
+ viewBox="0 0 24 24"
252
+ fill="none"
253
+ stroke="currentColor"
254
+ stroke-width="2"
255
+ stroke-linecap="round"
256
+ stroke-linejoin="round"
257
+ >
258
+ <line x1="5" y1="12" x2="19" y2="12" /><polyline points="12 5 19 12 12 19" />
259
+ </svg>
260
+ </button>
261
+ </div>
262
+ </div>
263
+ {/if}
264
+
265
+ <!-- Terminals -->
266
+ {#if isMulti}
267
+ <!-- Multi-runner: one terminal per lane -->
268
+ <div
269
+ class="lanes-grid"
270
+ style="grid-template-columns: repeat({Math.min(state.lanes.length, 2)}, 1fr)"
271
+ >
272
+ {#each state.lanes as lane}
273
+ <div class="lane-terminal">
274
+ <div class="lane-header">
275
+ <span
276
+ class="lane-dot"
277
+ style="background:{laneStatusColor(lane.status)}; {lane.status === 'running'
278
+ ? 'animation: dotPulse 1.6s ease-in-out infinite'
279
+ : ''}"
280
+ ></span>
281
+ <span class="lane-name">{lane.name}</span>
282
+ {#if lane.testCount}
283
+ <span class="lane-count">{lane.testCount} test{lane.testCount !== 1 ? 's' : ''}</span>
284
+ {/if}
285
+ <span class="lane-status-text" style="color:{laneStatusColor(lane.status)}">
286
+ {lane.status === 'done' ? 'Done' : lane.status === 'error' ? 'Failed' : 'Running'}
287
+ </span>
288
+ </div>
289
+ <div class="terminal-bar">
290
+ <span class="dot red"></span>
291
+ <span class="dot yellow"></span>
292
+ <span class="dot green"></span>
293
+ </div>
294
+ <pre class="terminal" bind:this={laneEls[lane.id]}>{lane.logs ||
295
+ '(waiting for output…)'}</pre>
296
+ </div>
297
+ {/each}
298
+ </div>
299
+ {:else}
300
+ <!-- Single terminal -->
301
+ <div class="terminal-wrap">
302
+ <div class="terminal-bar">
303
+ <span class="dot red"></span>
304
+ <span class="dot yellow"></span>
305
+ <span class="dot green"></span>
306
+ <span class="terminal-label">
307
+ {state.running ? 'Running…' : 'Finished'}
308
+ </span>
309
+ </div>
310
+ <pre class="terminal" bind:this={terminalEl}>{state.output}</pre>
311
+ </div>
312
+ {/if}
313
+ {/if}
314
+
315
+ <style>
316
+ .back-row {
317
+ margin-bottom: 1.5rem;
318
+ }
319
+
320
+ .back-link {
321
+ display: inline-flex;
322
+ align-items: center;
323
+ gap: 0.35rem;
324
+ font-size: 0.8125rem;
325
+ color: var(--text-muted);
326
+ text-decoration: none;
327
+ transition: color var(--duration-fast);
328
+ }
329
+
330
+ .back-link:hover {
331
+ color: var(--text);
332
+ }
333
+
334
+ /* ── Idle state ── */
335
+ .idle-state {
336
+ display: flex;
337
+ flex-direction: column;
338
+ align-items: center;
339
+ justify-content: center;
340
+ padding: 5rem 1rem;
341
+ text-align: center;
342
+ gap: 0.75rem;
343
+ }
344
+
345
+ .idle-icon {
346
+ color: var(--text-muted);
347
+ opacity: 0.4;
348
+ margin-bottom: 0.5rem;
349
+ }
350
+
351
+ .idle-state h2 {
352
+ font-size: 1.5rem;
353
+ font-weight: 400;
354
+ }
355
+
356
+ .idle-state p {
357
+ font-size: 0.9375rem;
358
+ color: var(--text-muted);
359
+ max-width: 360px;
360
+ }
361
+
362
+ .idle-link {
363
+ font-size: 0.875rem;
364
+ color: var(--accent);
365
+ text-decoration: none;
366
+ margin-top: 0.5rem;
367
+ }
368
+
369
+ .idle-link:hover {
370
+ text-decoration: underline;
371
+ }
372
+
373
+ /* ── Run header ── */
374
+ .run-header {
375
+ display: flex;
376
+ align-items: center;
377
+ justify-content: space-between;
378
+ gap: 1rem;
379
+ padding: 1rem 1.25rem;
380
+ margin-bottom: 0;
381
+ background: var(--bg-elevated);
382
+ border: 1px solid var(--border);
383
+ border-bottom: none;
384
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
385
+ animation: fadeUp 0.3s var(--ease-out) both;
386
+ }
387
+
388
+ .run-header.header-pass {
389
+ border-top: 3px solid var(--pass);
390
+ }
391
+ .run-header.header-fail {
392
+ border-top: 3px solid var(--fail);
393
+ }
394
+
395
+ .header-left {
396
+ display: flex;
397
+ align-items: center;
398
+ gap: 0.875rem;
399
+ flex-wrap: wrap;
400
+ }
401
+
402
+ /* Live badge */
403
+ .live-badge {
404
+ display: inline-flex;
405
+ align-items: center;
406
+ gap: 0.4rem;
407
+ font-size: 0.7rem;
408
+ font-weight: 600;
409
+ letter-spacing: 0.08em;
410
+ text-transform: uppercase;
411
+ color: #fff;
412
+ background: var(--accent);
413
+ padding: 0.2rem 0.6rem;
414
+ border-radius: 100px;
415
+ }
416
+
417
+ .live-dot {
418
+ width: 6px;
419
+ height: 6px;
420
+ border-radius: 50%;
421
+ background: rgba(255, 255, 255, 0.8);
422
+ animation: dotPulse 1.2s ease-in-out infinite;
423
+ }
424
+
425
+ @keyframes dotPulse {
426
+ 0%,
427
+ 100% {
428
+ opacity: 1;
429
+ transform: scale(1);
430
+ }
431
+ 50% {
432
+ opacity: 0.4;
433
+ transform: scale(0.65);
434
+ }
435
+ }
436
+
437
+ /* Result badges */
438
+ .result-badge {
439
+ display: inline-flex;
440
+ align-items: center;
441
+ gap: 0.4rem;
442
+ font-size: 0.7rem;
443
+ font-weight: 600;
444
+ letter-spacing: 0.08em;
445
+ text-transform: uppercase;
446
+ padding: 0.2rem 0.6rem;
447
+ border-radius: 100px;
448
+ }
449
+
450
+ .pass-badge {
451
+ background: var(--pass-soft);
452
+ color: var(--pass);
453
+ }
454
+ .fail-badge {
455
+ background: var(--fail-soft);
456
+ color: var(--fail);
457
+ }
458
+
459
+ .run-info {
460
+ display: flex;
461
+ align-items: center;
462
+ gap: 0.35rem;
463
+ flex-wrap: wrap;
464
+ }
465
+
466
+ .run-tag-label {
467
+ font-family: 'JetBrains Mono', monospace;
468
+ font-size: 0.8125rem;
469
+ font-weight: 500;
470
+ color: var(--text);
471
+ }
472
+
473
+ .run-sep {
474
+ color: var(--text-muted);
475
+ opacity: 0.4;
476
+ font-size: 0.75rem;
477
+ }
478
+
479
+ .run-detail {
480
+ font-size: 0.8rem;
481
+ color: var(--text-muted);
482
+ font-family: 'JetBrains Mono', monospace;
483
+ }
484
+
485
+ /* Cancel button */
486
+ .cancel-btn {
487
+ display: inline-flex;
488
+ align-items: center;
489
+ gap: 0.4rem;
490
+ font-family: var(--font-body);
491
+ font-size: 0.8rem;
492
+ font-weight: 500;
493
+ color: var(--fail);
494
+ background: var(--fail-soft);
495
+ border: 1px solid var(--fail);
496
+ border-radius: var(--radius-sm);
497
+ padding: 0.35rem 0.75rem;
498
+ cursor: pointer;
499
+ transition:
500
+ background var(--duration-fast),
501
+ color var(--duration-fast);
502
+ flex-shrink: 0;
503
+ }
504
+
505
+ .cancel-btn:hover {
506
+ background: var(--fail);
507
+ color: #fff;
508
+ }
509
+
510
+ /* ── Completion bar ── */
511
+ .completion-bar {
512
+ display: flex;
513
+ align-items: center;
514
+ justify-content: space-between;
515
+ padding: 0.75rem 1.25rem;
516
+ gap: 1rem;
517
+ border: 1px solid var(--border);
518
+ border-top: none;
519
+ border-bottom: none;
520
+ animation: fadeUp 0.3s var(--ease-out) both;
521
+ }
522
+
523
+ .completion-bar.pass-bar {
524
+ background: var(--pass-soft);
525
+ border-color: color-mix(in srgb, var(--pass) 30%, var(--border));
526
+ color: var(--pass);
527
+ }
528
+
529
+ .completion-bar.fail-bar {
530
+ background: var(--fail-soft);
531
+ border-color: color-mix(in srgb, var(--fail) 30%, var(--border));
532
+ color: var(--fail);
533
+ }
534
+
535
+ .completion-left {
536
+ display: flex;
537
+ align-items: center;
538
+ gap: 0.5rem;
539
+ font-size: 0.9rem;
540
+ font-weight: 500;
541
+ }
542
+
543
+ .completion-right {
544
+ display: flex;
545
+ align-items: center;
546
+ gap: 0.875rem;
547
+ }
548
+
549
+ .redirect-hint {
550
+ font-size: 0.8rem;
551
+ opacity: 0.7;
552
+ font-family: 'JetBrains Mono', monospace;
553
+ color: inherit;
554
+ }
555
+
556
+ .view-now-btn {
557
+ display: inline-flex;
558
+ align-items: center;
559
+ gap: 0.35rem;
560
+ font-family: var(--font-body);
561
+ font-size: 0.8125rem;
562
+ font-weight: 600;
563
+ color: inherit;
564
+ background: transparent;
565
+ border: 1.5px solid currentColor;
566
+ border-radius: var(--radius-sm);
567
+ padding: 0.3rem 0.75rem;
568
+ cursor: pointer;
569
+ transition: background var(--duration-fast);
570
+ }
571
+
572
+ .view-now-btn:hover {
573
+ background: rgba(0, 0, 0, 0.06);
574
+ }
575
+
576
+ /* ── Terminal ── */
577
+ .terminal-wrap {
578
+ border: 1px solid var(--border);
579
+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
580
+ overflow: hidden;
581
+ animation: fadeUp 0.35s var(--ease-out) 0.05s both;
582
+ }
583
+
584
+ .run-header + .terminal-wrap,
585
+ .run-header + .completion-bar + .terminal-wrap {
586
+ border-top: none;
587
+ }
588
+
589
+ .terminal-bar {
590
+ display: flex;
591
+ align-items: center;
592
+ gap: 6px;
593
+ padding: 0.6rem 0.875rem;
594
+ background: rgba(0, 0, 0, 0.35);
595
+ }
596
+
597
+ .dot {
598
+ display: block;
599
+ width: 10px;
600
+ height: 10px;
601
+ border-radius: 50%;
602
+ }
603
+
604
+ .dot.red {
605
+ background: #ff5f57;
606
+ }
607
+ .dot.yellow {
608
+ background: #febc2e;
609
+ }
610
+ .dot.green {
611
+ background: #28c840;
612
+ }
613
+
614
+ .terminal-label {
615
+ margin-left: auto;
616
+ font-size: 0.68rem;
617
+ font-family: 'JetBrains Mono', monospace;
618
+ font-weight: 500;
619
+ letter-spacing: 0.06em;
620
+ text-transform: uppercase;
621
+ color: rgba(255, 255, 255, 0.3);
622
+ }
623
+
624
+ .terminal {
625
+ font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace;
626
+ font-size: 0.8rem;
627
+ line-height: 1.75;
628
+ background: var(--terminal-bg);
629
+ color: var(--terminal-text);
630
+ padding: 1rem 1.25rem;
631
+ height: calc(100vh - 320px);
632
+ min-height: 240px;
633
+ overflow-y: auto;
634
+ white-space: pre-wrap;
635
+ word-break: break-word;
636
+ margin: 0;
637
+ }
638
+
639
+ .terminal::-webkit-scrollbar {
640
+ width: 4px;
641
+ }
642
+ .terminal::-webkit-scrollbar-track {
643
+ background: transparent;
644
+ }
645
+ .terminal::-webkit-scrollbar-thumb {
646
+ background: rgba(255, 255, 255, 0.1);
647
+ border-radius: 2px;
648
+ }
649
+
650
+ /* ── Multi-runner lanes ── */
651
+ .lanes-grid {
652
+ display: grid;
653
+ gap: 1rem;
654
+ animation: fadeUp 0.35s var(--ease-out) both;
655
+ }
656
+
657
+ .lane-terminal {
658
+ border: 1px solid var(--border);
659
+ border-radius: var(--radius-md);
660
+ overflow: hidden;
661
+ }
662
+
663
+ .lane-header {
664
+ display: flex;
665
+ align-items: center;
666
+ gap: 0.5rem;
667
+ padding: 0.625rem 0.875rem;
668
+ background: var(--bg-subtle);
669
+ border-bottom: 1px solid var(--border);
670
+ }
671
+
672
+ .lane-dot {
673
+ width: 7px;
674
+ height: 7px;
675
+ border-radius: 50%;
676
+ flex-shrink: 0;
677
+ }
678
+
679
+ .lane-name {
680
+ font-size: 0.8125rem;
681
+ font-weight: 500;
682
+ color: var(--text);
683
+ }
684
+
685
+ .lane-count {
686
+ font-size: 0.72rem;
687
+ color: var(--text-muted);
688
+ font-family: 'JetBrains Mono', monospace;
689
+ }
690
+
691
+ .lane-status-text {
692
+ margin-left: auto;
693
+ font-size: 0.7rem;
694
+ font-weight: 600;
695
+ letter-spacing: 0.06em;
696
+ text-transform: uppercase;
697
+ }
698
+
699
+ .lane-terminal .terminal {
700
+ height: 280px;
701
+ min-height: unset;
702
+ font-size: 0.75rem;
703
+ }
704
+ </style>