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
@@ -16,58 +16,143 @@
16
16
  -->
17
17
 
18
18
  <script>
19
- import { onMount } from 'svelte';
20
- import { slide, fly } from 'svelte/transition';
19
+ import { onMount, onDestroy } from 'svelte';
20
+ import { fly, slide } from 'svelte/transition';
21
+ import { goto } from '$app/navigation';
21
22
  import { io } from 'socket.io-client';
22
23
  import {
23
24
  socket,
24
25
  runnerState,
25
26
  runnerConfig,
26
27
  panelExpanded,
28
+ builtInEnabled,
27
29
  triggerRun,
28
30
  testsVersion,
29
31
  reportsVersion,
30
32
  activeCronJobs
31
33
  } from '$lib/stores/runner';
32
- import { fetchLatestReport, reportUrl } from '$lib/api/reports';
33
- import Terminal from '$lib/components/ui/Terminal.svelte';
34
- import Button from '$lib/components/ui/Button.svelte';
34
+ import { fetchLatestReportId, reportUrl } from '$lib/api/reports';
35
+ import { fetchRunners } from '$lib/api/runners';
36
+ import { API_BASE, BROWSERS } from '$lib/constants';
37
+ import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
35
38
 
36
- const WORKER_OPTIONS = [1, 2, 4, 8];
39
+ let availableRunners = [];
40
+ let browserOpen = false;
41
+ let runnersOpen = false;
42
+ let runAllModalOpen = false;
43
+
44
+ function clickOutside(node) {
45
+ function handle(e) {
46
+ if (!node.contains(e.target)) node.dispatchEvent(new CustomEvent('clickoutside'));
47
+ }
48
+ document.addEventListener('click', handle, true);
49
+ return {
50
+ destroy() {
51
+ document.removeEventListener('click', handle, true);
52
+ }
53
+ };
54
+ }
55
+
56
+ let _unsubConfig, _unsubExpanded, _unsubBuiltIn, _socket;
37
57
 
38
58
  onMount(() => {
39
- const s = io('http://localhost:3001');
59
+ try {
60
+ const saved = localStorage.getItem('plum:runnerConfig');
61
+ if (saved) runnerConfig.update((c) => ({ ...c, ...JSON.parse(saved) }));
62
+ } catch {}
63
+ try {
64
+ const exp = localStorage.getItem('plum:panelExpanded');
65
+ if (exp !== null) panelExpanded.set(exp === 'true');
66
+ } catch {}
67
+ try {
68
+ const bi = localStorage.getItem('plum:builtInEnabled');
69
+ if (bi !== null) builtInEnabled.set(bi !== 'false');
70
+ } catch {}
71
+
72
+ fetchRunners()
73
+ .then((r) => {
74
+ availableRunners = r;
75
+ })
76
+ .catch(() => {});
77
+
78
+ _unsubConfig = runnerConfig.subscribe((v) => {
79
+ try {
80
+ localStorage.setItem('plum:runnerConfig', JSON.stringify(v));
81
+ } catch {}
82
+ });
83
+ _unsubExpanded = panelExpanded.subscribe((v) => {
84
+ try {
85
+ localStorage.setItem('plum:panelExpanded', String(v));
86
+ } catch {}
87
+ });
88
+ _unsubBuiltIn = builtInEnabled.subscribe((v) => {
89
+ try {
90
+ localStorage.setItem('plum:builtInEnabled', String(v));
91
+ } catch {}
92
+ runnerConfig.update((c) => {
93
+ if (!v && c.selectedRunners.includes('built-in')) {
94
+ const others = c.selectedRunners.filter((r) => r !== 'built-in');
95
+ return { ...c, selectedRunners: others.length > 0 ? others : c.selectedRunners };
96
+ }
97
+ return c;
98
+ });
99
+ });
100
+
101
+ const s = io(API_BASE);
102
+ _socket = s;
40
103
  socket.set(s);
41
104
 
42
105
  s.on('log', (data) => {
43
106
  runnerState.update((r) => ({ ...r, output: r.output + data + '\n' }));
44
107
  });
45
108
 
46
- s.on('done', async (code) => {
47
- const report = await fetchLatestReport().catch(() => null);
48
- const passed = code === 0;
109
+ s.on('done', (code) => {
110
+ const passed = code === 0 || code === null;
111
+ const cancelled = code === 130;
112
+ fetchLatestReportId()
113
+ .catch(() => null)
114
+ .then((id) => {
115
+ runnerState.update((r) => ({
116
+ ...r,
117
+ output:
118
+ r.output +
119
+ (cancelled ? '' : passed ? '\n✓ All tests passed\n' : '\n✗ Some tests failed\n'),
120
+ running: false,
121
+ testCompleted: !cancelled,
122
+ latestReportId: cancelled ? null : id,
123
+ status: cancelled ? 'idle' : passed ? 'pass' : 'fail',
124
+ currentRun: cancelled ? null : r.currentRun
125
+ }));
126
+ });
127
+ });
128
+
129
+ s.on('runner-lanes-init', (lanes) => {
49
130
  runnerState.update((r) => ({
50
131
  ...r,
51
- output: r.output + (passed ? '✓ All tests passed\n' : ' Some tests failed\n'),
52
- running: false,
53
- testCompleted: true,
54
- latestReport: report,
55
- status: passed ? 'pass' : 'fail'
132
+ lanes: lanes.map((l) => ({ ...l, status: 'running', logs: '' }))
56
133
  }));
57
134
  });
58
135
 
59
- s.on('tests-changed', () => {
60
- testsVersion.update((v) => v + 1);
136
+ s.on('runner-lane-log', ({ id, log }) => {
137
+ runnerState.update((r) => ({
138
+ ...r,
139
+ lanes: r.lanes.map((l) => (l.id === id ? { ...l, logs: l.logs + log } : l))
140
+ }));
61
141
  });
62
142
 
63
- s.on('report-ready', () => {
64
- reportsVersion.update((v) => v + 1);
143
+ s.on('runner-lane-status', ({ id, status }) => {
144
+ runnerState.update((r) => ({
145
+ ...r,
146
+ lanes: r.lanes.map((l) => (l.id === id ? { ...l, status } : l))
147
+ }));
65
148
  });
66
149
 
150
+ s.on('tests-changed', () => testsVersion.update((v) => v + 1));
151
+ s.on('report-ready', () => reportsVersion.update((v) => v + 1));
152
+
67
153
  s.on('cron-start', ({ taskName }) => {
68
154
  activeCronJobs.update((j) => ({ ...j, [taskName]: true }));
69
155
  });
70
-
71
156
  s.on('cron-done', ({ taskName }) => {
72
157
  activeCronJobs.update((j) => {
73
158
  const next = { ...j };
@@ -76,13 +161,17 @@
76
161
  });
77
162
  reportsVersion.update((v) => v + 1);
78
163
  });
164
+ });
79
165
 
80
- return () => s.disconnect();
166
+ onDestroy(() => {
167
+ _unsubConfig?.();
168
+ _unsubExpanded?.();
169
+ _unsubBuiltIn?.();
170
+ _socket?.disconnect();
81
171
  });
82
172
 
83
173
  $: state = $runnerState;
84
174
  $: cfg = $runnerConfig;
85
- $: dots = Array.from({ length: cfg.workers });
86
175
  $: cronJobs = Object.keys($activeCronJobs);
87
176
  $: anyCronRunning = cronJobs.length > 0;
88
177
  $: anyRunning = state.running || anyCronRunning;
@@ -99,43 +188,295 @@
99
188
  : 'var(--border)';
100
189
 
101
190
  $: statusLabel = state.running
102
- ? 'running'
191
+ ? 'Running'
103
192
  : state.status === 'pass'
104
- ? 'passed'
193
+ ? 'Passed'
105
194
  : state.status === 'fail'
106
- ? 'failed'
195
+ ? 'Failed'
107
196
  : anyCronRunning
108
- ? 'scheduled'
109
- : 'ready';
197
+ ? 'Scheduled'
198
+ : 'Ready';
199
+
200
+ $: currentBrowser = BROWSERS.find((b) => b.id === cfg.browser) ?? BROWSERS[0];
201
+
202
+ $: runnerSummary =
203
+ cfg.selectedRunners.length === 1 && cfg.selectedRunners[0] === 'built-in'
204
+ ? 'Built-in'
205
+ : cfg.selectedRunners.length === 1
206
+ ? (availableRunners.find((r) => r.id === cfg.selectedRunners[0])?.name ?? '1 node')
207
+ : `${cfg.selectedRunners.length} nodes`;
208
+
209
+ function handleRunClick() {
210
+ if ($runnerConfig.testID.trim() === '') {
211
+ runAllModalOpen = true;
212
+ } else {
213
+ triggerRun();
214
+ }
215
+ }
110
216
 
111
217
  function handleKeydown(e) {
112
- if (e.key === 'Enter' && !state.running) triggerRun();
218
+ if (e.key === 'Enter' && !state.running) handleRunClick();
219
+ }
220
+
221
+ function adjustWorkers(delta) {
222
+ runnerConfig.update((c) => ({
223
+ ...c,
224
+ workers: Math.max(1, Math.min(16, c.workers + delta))
225
+ }));
226
+ }
227
+
228
+ function toggleRunner(id) {
229
+ runnerConfig.update((c) => {
230
+ const current = c.selectedRunners;
231
+ if (current.includes(id) && current.length === 1) return c;
232
+ const next = current.includes(id) ? current.filter((r) => r !== id) : [...current, id];
233
+ return { ...c, selectedRunners: next };
234
+ });
235
+ }
236
+
237
+ function isRunnerSelected(id) {
238
+ return $runnerConfig.selectedRunners.includes(id);
113
239
  }
114
240
  </script>
115
241
 
242
+ <!-- Run all disclaimer -->
243
+ <ConfirmModal
244
+ bind:open={runAllModalOpen}
245
+ title="Run all tests?"
246
+ confirmLabel="Run all tests"
247
+ on:confirm={() => {
248
+ runAllModalOpen = false;
249
+ triggerRun();
250
+ }}
251
+ >
252
+ No tag or filter is set. This will run <strong>every test</strong> in the suite, which may take a while.
253
+ </ConfirmModal>
254
+
116
255
  <div class="panel" class:expanded={$panelExpanded}>
117
- <!-- Header bar — always visible -->
256
+ <div
257
+ class="scan-line"
258
+ class:scanning={state.running}
259
+ class:line-pass={state.status === 'pass' && !state.running}
260
+ class:line-fail={state.status === 'fail' && !state.running}
261
+ ></div>
262
+
263
+ <!-- ── Control bar ── -->
118
264
  <div class="bar">
265
+ <!-- Left: status + view report -->
119
266
  <div class="bar-left">
120
- <span class="status-dot" class:pulse={state.running} style="background: {statusColor}"></span>
121
- <span class="status-label">{statusLabel}</span>
122
- {#if state.lastRunId}
123
- <span class="run-id">{state.lastRunId || '(all)'}</span>
267
+ <div class="bar-status">
268
+ <span class="status-dot" class:pulse={state.running} style="background:{statusColor}"
269
+ ></span>
270
+ <span class="status-word" style="color:{statusColor}">{statusLabel}</span>
271
+ {#if state.lastRunId}
272
+ <span class="run-tag">{state.lastRunId || 'all tests'}</span>
273
+ {/if}
274
+ </div>
275
+ {#if state.testCompleted && state.latestReportId}
276
+ <a
277
+ href={reportUrl(state.latestReportId)}
278
+ class="view-report-btn"
279
+ transition:fly={{ x: -6, duration: 200 }}
280
+ >
281
+ View Report
282
+ <svg
283
+ width="11"
284
+ height="11"
285
+ viewBox="0 0 24 24"
286
+ fill="none"
287
+ stroke="currentColor"
288
+ stroke-width="2"
289
+ stroke-linecap="round"
290
+ stroke-linejoin="round"
291
+ >
292
+ <line x1="5" y1="12" x2="19" y2="12" /><polyline points="12 5 19 12 12 19" />
293
+ </svg>
294
+ </a>
124
295
  {/if}
125
296
  </div>
126
297
 
127
- <div class="bar-dots" title="{cfg.workers} worker{cfg.workers !== 1 ? 's' : ''}">
128
- {#each dots as _, i}
129
- <span
130
- class="dot"
131
- class:running={state.running}
132
- class:pass={state.status === 'pass'}
133
- class:fail={state.status === 'fail'}
134
- style="animation-delay: {i * 180}ms"
135
- ></span>
136
- {/each}
298
+ <span class="flex-gap"></span>
299
+
300
+ <!-- Middle: tag filter + browser + workers + runner dropdowns -->
301
+ <div class="bar-center">
302
+ <!-- Tag input -->
303
+ <div class="input-wrap">
304
+ <svg
305
+ class="input-icon"
306
+ width="12"
307
+ height="12"
308
+ viewBox="0 0 24 24"
309
+ fill="none"
310
+ stroke="currentColor"
311
+ stroke-width="2"
312
+ stroke-linecap="round"
313
+ stroke-linejoin="round"
314
+ >
315
+ <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
316
+ </svg>
317
+ <input
318
+ type="text"
319
+ class="tag-input"
320
+ value={$runnerConfig.testID}
321
+ placeholder="@tag or leave blank for all tests"
322
+ on:input={(e) => runnerConfig.update((c) => ({ ...c, testID: e.currentTarget.value }))}
323
+ on:keydown={handleKeydown}
324
+ disabled={state.running}
325
+ />
326
+ </div>
327
+
328
+ <div class="ctrl-divider"></div>
329
+
330
+ <!-- Workers stepper -->
331
+ <div class="ctrl-group">
332
+ <span class="ctrl-label">Workers</span>
333
+ <div class="stepper">
334
+ <button
335
+ class="step-btn"
336
+ on:click={() => adjustWorkers(-1)}
337
+ disabled={cfg.workers <= 1 || state.running}>−</button
338
+ >
339
+ <span class="step-val">{cfg.workers}</span>
340
+ <button
341
+ class="step-btn"
342
+ on:click={() => adjustWorkers(1)}
343
+ disabled={cfg.workers >= 16 || state.running}>+</button
344
+ >
345
+ </div>
346
+ </div>
347
+
348
+ <div class="ctrl-divider"></div>
349
+
350
+ <!-- Browser -->
351
+ <div class="ctrl-group">
352
+ <span class="ctrl-label">Browser</span>
353
+ <div class="dropdown-wrap" use:clickOutside on:clickoutside={() => (browserOpen = false)}>
354
+ <button
355
+ class="dropdown-trigger"
356
+ class:open={browserOpen}
357
+ on:click={() => {
358
+ if (!state.running) browserOpen = !browserOpen;
359
+ }}
360
+ disabled={state.running}
361
+ >
362
+ <span>{currentBrowser.label}</span>
363
+ <svg
364
+ width="10"
365
+ height="10"
366
+ viewBox="0 0 24 24"
367
+ fill="none"
368
+ stroke="currentColor"
369
+ stroke-width="2.5"
370
+ stroke-linecap="round"
371
+ class="trigger-chevron"
372
+ class:open={browserOpen}
373
+ >
374
+ <polyline points="6 9 12 15 18 9" />
375
+ </svg>
376
+ </button>
377
+ {#if browserOpen}
378
+ <div class="dropdown-menu" transition:fly={{ y: 6, duration: 130 }}>
379
+ {#each BROWSERS as b}
380
+ <button
381
+ class="dropdown-item"
382
+ class:active={cfg.browser === b.id}
383
+ on:click={() => {
384
+ runnerConfig.update((c) => ({ ...c, browser: b.id }));
385
+ browserOpen = false;
386
+ }}
387
+ >
388
+ {b.label}
389
+ </button>
390
+ {/each}
391
+ </div>
392
+ {/if}
393
+ </div>
394
+ </div>
395
+
396
+ <!-- Runners (only when external runners exist) -->
397
+ {#if availableRunners.length > 0}
398
+ <div class="ctrl-divider"></div>
399
+ <div class="ctrl-group">
400
+ <span class="ctrl-label">Runners</span>
401
+ <div class="dropdown-wrap" use:clickOutside on:clickoutside={() => (runnersOpen = false)}>
402
+ <button
403
+ class="dropdown-trigger"
404
+ class:open={runnersOpen}
405
+ class:has-remote={cfg.selectedRunners.some((r) => r !== 'built-in')}
406
+ on:click={() => {
407
+ if (!state.running) runnersOpen = !runnersOpen;
408
+ }}
409
+ disabled={state.running}
410
+ >
411
+ <span>{runnerSummary}</span>
412
+ <svg
413
+ width="10"
414
+ height="10"
415
+ viewBox="0 0 24 24"
416
+ fill="none"
417
+ stroke="currentColor"
418
+ stroke-width="2.5"
419
+ stroke-linecap="round"
420
+ class="trigger-chevron"
421
+ class:open={runnersOpen}
422
+ >
423
+ <polyline points="6 9 12 15 18 9" />
424
+ </svg>
425
+ </button>
426
+ {#if runnersOpen}
427
+ <div class="dropdown-menu runners-menu" transition:fly={{ y: 6, duration: 130 }}>
428
+ {#if $builtInEnabled}
429
+ <label class="runner-option">
430
+ <input
431
+ type="checkbox"
432
+ checked={isRunnerSelected('built-in')}
433
+ on:change={() => toggleRunner('built-in')}
434
+ />
435
+ <span class="runner-dot built-in"></span>
436
+ <span>Built-in</span>
437
+ </label>
438
+ {/if}
439
+ {#each availableRunners as r}
440
+ <label class="runner-option">
441
+ <input
442
+ type="checkbox"
443
+ checked={isRunnerSelected(r.id)}
444
+ on:change={() => toggleRunner(r.id)}
445
+ />
446
+ <span class="runner-dot remote"></span>
447
+ <span>{r.name}</span>
448
+ </label>
449
+ {/each}
450
+ </div>
451
+ {/if}
452
+ </div>
453
+ </div>
454
+ {/if}
455
+
456
+ <div class="ctrl-divider"></div>
457
+
458
+ <!-- Run button -->
459
+ <button
460
+ class="run-btn"
461
+ class:is-running={state.running}
462
+ on:click={handleRunClick}
463
+ disabled={state.running}
464
+ >
465
+ {#if state.running}
466
+ <span class="run-spinner"></span>
467
+ Running
468
+ {:else}
469
+ <svg width="9" height="10" viewBox="0 0 10 12" fill="currentColor" stroke="none">
470
+ <polygon points="0,0 10,6 0,12" />
471
+ </svg>
472
+ Run
473
+ {/if}
474
+ </button>
137
475
  </div>
138
476
 
477
+ <span class="flex-gap-sm"></span>
478
+
479
+ <!-- Right: expand toggle -->
139
480
  <button
140
481
  class="expand-btn"
141
482
  on:click={() => panelExpanded.update((v) => !v)}
@@ -150,80 +491,89 @@
150
491
  stroke-width="2"
151
492
  stroke-linecap="round"
152
493
  stroke-linejoin="round"
153
- class:rotated={$panelExpanded}
494
+ class:flipped={$panelExpanded}
154
495
  >
155
496
  <polyline points="18 15 12 9 6 15" />
156
497
  </svg>
157
498
  </button>
158
499
  </div>
159
500
 
160
- <!-- Body visible when expanded -->
501
+ <!-- ── Expanded: active runs only ── -->
161
502
  {#if $panelExpanded}
162
- <div class="body" transition:slide={{ duration: 220 }}>
163
- <div class="controls">
164
- <input
165
- type="text"
166
- class="field-input run-input"
167
- bind:value={$runnerConfig.testID}
168
- placeholder="@test-1 or @suite-login or blank for all"
169
- on:keydown={handleKeydown}
170
- disabled={state.running}
171
- />
172
-
173
- <div class="workers-control">
174
- {#each WORKER_OPTIONS as n}
175
- <button
176
- class="worker-btn"
177
- class:active={cfg.workers === n}
178
- on:click={() => runnerConfig.update((c) => ({ ...c, workers: n }))}
179
- >
180
- {n}
181
- </button>
182
- {/each}
183
- </div>
184
-
185
- <Button on:click={() => triggerRun()} disabled={state.running}>
186
- {state.running ? 'Running…' : 'Run'}
187
- </Button>
188
- </div>
503
+ <div class="body" transition:slide={{ duration: 200 }}>
504
+ {#if state.running}
505
+ <!-- Manual test running -->
506
+ <a href="/reports/live" class="run-card active-run">
507
+ <span class="run-card-dot pulse-accent"></span>
508
+ <div class="run-card-info">
509
+ <span class="run-card-label">Manual run</span>
510
+ {#if state.currentRun}
511
+ <span class="run-card-meta">
512
+ {state.currentRun.tag || 'all tests'}
513
+ <span class="meta-dot">·</span>
514
+ {state.currentRun.workers}w
515
+ <span class="meta-dot">·</span>
516
+ {state.currentRun.browser}
517
+ {#if state.currentRun.runners?.length > 1}
518
+ <span class="meta-dot">·</span>
519
+ {state.currentRun.runners.length} runners
520
+ {/if}
521
+ </span>
522
+ {/if}
523
+ </div>
524
+ <span class="run-card-badge">Live</span>
525
+ <svg
526
+ width="13"
527
+ height="13"
528
+ viewBox="0 0 24 24"
529
+ fill="none"
530
+ stroke="currentColor"
531
+ stroke-width="2"
532
+ stroke-linecap="round"
533
+ class="run-card-arrow"
534
+ >
535
+ <line x1="5" y1="12" x2="19" y2="12" /><polyline points="12 5 19 12 12 19" />
536
+ </svg>
537
+ </a>
538
+ {/if}
189
539
 
190
- <div class="main-row">
191
- <!-- Terminal column -->
192
- <div class="terminal-col">
193
- <Terminal output={state.output} />
194
- {#if state.testCompleted && state.latestReport}
195
- <div class="report-row" transition:fly={{ y: 6, duration: 200 }}>
196
- <a href={reportUrl(state.latestReport)} target="_blank" rel="noopener noreferrer">
197
- <Button variant="outline" size="sm">View Report →</Button>
198
- </a>
199
- </div>
200
- {/if}
540
+ {#each cronJobs as name}
541
+ <div class="run-card cron-run" transition:fly={{ x: -4, duration: 160 }}>
542
+ <span class="run-card-dot pulse-pass"></span>
543
+ <div class="run-card-info">
544
+ <span class="run-card-label">{name}</span>
545
+ <span class="run-card-meta">Scheduled run</span>
546
+ </div>
547
+ <span class="run-card-badge cron-badge">Scheduled</span>
201
548
  </div>
549
+ {/each}
202
550
 
203
- <!-- Sidebar: scheduled jobs -->
204
- <div class="sidebar">
205
- <div class="sidebar-section">
206
- <span class="sidebar-label">Scheduled</span>
207
- {#if cronJobs.length === 0}
208
- <span class="sidebar-empty">none running</span>
209
- {:else}
210
- <ul class="cron-list">
211
- {#each cronJobs as name}
212
- <li class="cron-item" transition:fly={{ x: -6, duration: 180 }}>
213
- <span class="cron-dot"></span>
214
- <span class="cron-name">{name}</span>
215
- </li>
216
- {/each}
217
- </ul>
218
- {/if}
219
- </div>
551
+ {#if !anyRunning}
552
+ <div class="empty-runs">
553
+ <svg
554
+ width="18"
555
+ height="18"
556
+ viewBox="0 0 24 24"
557
+ fill="none"
558
+ stroke="currentColor"
559
+ stroke-width="1.5"
560
+ stroke-linecap="round"
561
+ stroke-linejoin="round"
562
+ class="empty-icon"
563
+ >
564
+ <circle cx="12" cy="12" r="10" />
565
+ <line x1="10" y1="15" x2="10" y2="12" />
566
+ <line x1="10" y1="9" x2="10.01" y2="9" />
567
+ </svg>
568
+ No tests currently running
220
569
  </div>
221
- </div>
570
+ {/if}
222
571
  </div>
223
572
  {/if}
224
573
  </div>
225
574
 
226
575
  <style>
576
+ /* ── Panel shell ── */
227
577
  .panel {
228
578
  position: fixed;
229
579
  bottom: 0;
@@ -232,286 +582,596 @@
232
582
  z-index: 200;
233
583
  background: var(--bg-elevated);
234
584
  border-top: 1px solid var(--border);
235
- box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.06);
236
- transition: box-shadow var(--duration-base) var(--ease-out);
585
+ box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.07);
586
+ }
587
+
588
+ /* ── Scan-line ── */
589
+ .scan-line {
590
+ height: 2px;
591
+ background: var(--border);
592
+ transition: background 0.5s ease;
593
+ overflow: hidden;
594
+ position: relative;
595
+ }
596
+
597
+ .scan-line.scanning {
598
+ background: color-mix(in srgb, var(--accent) 30%, var(--border));
237
599
  }
238
600
 
239
- .panel.expanded {
240
- box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.1);
601
+ .scan-line.scanning::after {
602
+ content: '';
603
+ position: absolute;
604
+ inset: 0;
605
+ width: 35%;
606
+ background: linear-gradient(90deg, transparent, var(--accent), transparent);
607
+ animation: scan 1.8s ease-in-out infinite;
241
608
  }
242
609
 
243
- /* ── Header bar ── */
610
+ .scan-line.line-pass {
611
+ background: var(--pass);
612
+ }
613
+ .scan-line.line-fail {
614
+ background: var(--fail);
615
+ }
616
+
617
+ @keyframes scan {
618
+ 0% {
619
+ transform: translateX(-100%);
620
+ }
621
+ 100% {
622
+ transform: translateX(400%);
623
+ }
624
+ }
625
+
626
+ /* ── Bar ── */
244
627
  .bar {
245
628
  display: flex;
246
629
  align-items: center;
630
+ height: 52px;
631
+ padding: 0 1rem 0 1.25rem;
247
632
  gap: 0.75rem;
248
- padding: 0 1.5rem;
249
- height: 48px;
250
- cursor: default;
251
633
  }
252
634
 
635
+ .flex-gap {
636
+ flex: 1;
637
+ }
638
+ .flex-gap-sm {
639
+ width: 0.5rem;
640
+ }
641
+
642
+ /* ── Left: status ── */
253
643
  .bar-left {
644
+ display: flex;
645
+ align-items: center;
646
+ gap: 0.75rem;
647
+ flex-shrink: 0;
648
+ }
649
+
650
+ .bar-status {
254
651
  display: flex;
255
652
  align-items: center;
256
653
  gap: 0.5rem;
257
- flex: 1;
258
- min-width: 0;
259
654
  }
260
655
 
261
656
  .status-dot {
262
- flex-shrink: 0;
263
- width: 8px;
264
- height: 8px;
657
+ width: 7px;
658
+ height: 7px;
265
659
  border-radius: 50%;
266
- transition: background var(--duration-base);
660
+ flex-shrink: 0;
661
+ transition: background 0.4s ease;
267
662
  }
268
663
 
269
664
  .status-dot.pulse {
270
- animation: statusPulse 1.6s ease-in-out infinite;
665
+ animation: dotPulse 1.6s ease-in-out infinite;
271
666
  }
272
667
 
273
- @keyframes statusPulse {
668
+ @keyframes dotPulse {
274
669
  0%,
275
670
  100% {
276
671
  opacity: 1;
277
672
  transform: scale(1);
278
673
  }
279
674
  50% {
280
- opacity: 0.5;
281
- transform: scale(0.7);
675
+ opacity: 0.4;
676
+ transform: scale(0.65);
282
677
  }
283
678
  }
284
679
 
285
- .status-label {
286
- font-size: 0.7rem;
287
- font-weight: 500;
288
- letter-spacing: 0.07em;
680
+ .status-word {
681
+ font-size: 0.72rem;
682
+ font-weight: 600;
683
+ letter-spacing: 0.05em;
289
684
  text-transform: uppercase;
290
- color: var(--text-muted);
291
- flex-shrink: 0;
685
+ transition: color 0.4s ease;
686
+ white-space: nowrap;
292
687
  }
293
688
 
294
- .run-id {
295
- font-size: 0.75rem;
296
- color: var(--text-muted);
689
+ .run-tag {
690
+ font-size: 0.7rem;
297
691
  font-family: 'JetBrains Mono', monospace;
692
+ color: var(--text-muted);
693
+ background: var(--bg-subtle);
694
+ border: 1px solid var(--border);
695
+ border-radius: 100px;
696
+ padding: 0.1rem 0.45rem;
298
697
  white-space: nowrap;
299
698
  overflow: hidden;
300
699
  text-overflow: ellipsis;
700
+ max-width: 160px;
701
+ }
702
+
703
+ .view-report-btn {
704
+ display: inline-flex;
705
+ align-items: center;
706
+ gap: 0.3rem;
707
+ font-size: 0.75rem;
708
+ font-weight: 500;
709
+ color: var(--accent);
710
+ text-decoration: none;
711
+ white-space: nowrap;
712
+ border: 1px solid var(--accent);
713
+ border-radius: var(--radius-sm);
714
+ padding: 0.2rem 0.6rem;
715
+ background: var(--accent-soft);
716
+ transition:
717
+ background var(--duration-fast),
718
+ color var(--duration-fast);
301
719
  }
302
720
 
303
- /* ── Worker dots ── */
304
- .bar-dots {
721
+ .view-report-btn:hover {
722
+ background: var(--accent);
723
+ color: #fff;
724
+ }
725
+
726
+ /* ── Center: controls ── */
727
+ .bar-center {
305
728
  display: flex;
306
729
  align-items: center;
307
- gap: 4px;
308
- flex-shrink: 0;
730
+ gap: 0.625rem;
309
731
  }
310
732
 
311
- .dot {
312
- width: 7px;
313
- height: 7px;
314
- border-radius: 50%;
315
- background: var(--border);
316
- transition:
317
- background var(--duration-base),
318
- transform var(--duration-fast);
733
+ /* Tag input */
734
+ .input-wrap {
735
+ position: relative;
736
+ display: flex;
737
+ align-items: center;
319
738
  }
320
739
 
321
- .dot.running {
322
- background: var(--pass);
323
- animation: dotBeat 1.4s ease-in-out infinite;
740
+ .input-icon {
741
+ position: absolute;
742
+ left: 0.6rem;
743
+ color: var(--text-muted);
744
+ pointer-events: none;
745
+ opacity: 0.6;
324
746
  }
325
747
 
326
- .dot.pass {
327
- background: var(--pass);
748
+ .tag-input {
749
+ height: 28px;
750
+ padding: 0 0.75rem 0 2rem;
751
+ font-size: 0.8rem;
752
+ font-family: 'JetBrains Mono', monospace;
753
+ background: var(--bg-subtle);
754
+ border: 1px solid var(--border);
755
+ border-radius: var(--radius-sm);
756
+ color: var(--text);
757
+ outline: none;
758
+ transition: border-color var(--duration-fast);
759
+ width: 200px;
328
760
  }
329
761
 
330
- .dot.fail {
331
- background: var(--fail);
762
+ .tag-input:focus {
763
+ border-color: var(--accent);
764
+ }
765
+ .tag-input::placeholder {
766
+ color: var(--text-muted);
767
+ opacity: 0.45;
768
+ }
769
+ .tag-input:disabled {
770
+ opacity: 0.45;
332
771
  }
333
772
 
334
- @keyframes dotBeat {
335
- 0%,
336
- 100% {
337
- opacity: 1;
338
- transform: scale(1);
339
- }
340
- 50% {
341
- opacity: 0.35;
342
- transform: scale(0.65);
343
- }
773
+ .ctrl-divider {
774
+ width: 1px;
775
+ height: 24px;
776
+ background: var(--border);
777
+ flex-shrink: 0;
344
778
  }
345
779
 
346
- .expand-btn {
780
+ /* Workers stepper */
781
+ .ctrl-group {
782
+ display: flex;
783
+ flex-direction: column;
784
+ gap: 2px;
785
+ }
786
+
787
+ .ctrl-label {
788
+ font-size: 0.555rem;
789
+ font-weight: 600;
790
+ letter-spacing: 0.09em;
791
+ text-transform: uppercase;
792
+ color: var(--text-muted);
793
+ opacity: 0.65;
794
+ line-height: 1;
795
+ }
796
+
797
+ .stepper {
347
798
  display: flex;
348
799
  align-items: center;
349
- justify-content: center;
350
- width: 28px;
351
- height: 28px;
800
+ background: var(--bg-subtle);
801
+ border: 1px solid var(--border);
352
802
  border-radius: var(--radius-sm);
803
+ overflow: hidden;
804
+ }
805
+
806
+ .step-btn {
807
+ width: 20px;
808
+ height: 24px;
809
+ display: flex;
810
+ align-items: center;
811
+ justify-content: center;
353
812
  border: none;
354
813
  background: transparent;
355
814
  color: var(--text-muted);
356
815
  cursor: pointer;
357
- flex-shrink: 0;
816
+ font-size: 0.875rem;
817
+ font-weight: 400;
818
+ line-height: 1;
358
819
  transition:
359
- background var(--duration-fast),
360
- color var(--duration-fast);
820
+ color var(--duration-fast),
821
+ background var(--duration-fast);
361
822
  }
362
823
 
363
- .expand-btn:hover {
824
+ .step-btn:hover:not(:disabled) {
825
+ color: var(--text);
826
+ background: var(--bg-elevated);
827
+ }
828
+ .step-btn:disabled {
829
+ opacity: 0.3;
830
+ cursor: default;
831
+ }
832
+
833
+ .step-val {
834
+ min-width: 20px;
835
+ text-align: center;
836
+ font-size: 0.78rem;
837
+ font-weight: 500;
838
+ color: var(--text);
839
+ font-family: 'JetBrains Mono', monospace;
840
+ line-height: 24px;
841
+ border-left: 1px solid var(--border);
842
+ border-right: 1px solid var(--border);
843
+ }
844
+
845
+ /* Dropdown */
846
+ .dropdown-wrap {
847
+ position: relative;
848
+ }
849
+
850
+ .dropdown-trigger {
851
+ display: inline-flex;
852
+ align-items: center;
853
+ gap: 0.3rem;
854
+ height: 24px;
855
+ padding: 0 0.5rem;
364
856
  background: var(--bg-subtle);
857
+ border: 1px solid var(--border);
858
+ border-radius: var(--radius-sm);
859
+ font-family: var(--font-body);
860
+ font-size: 0.78rem;
365
861
  color: var(--text);
862
+ cursor: pointer;
863
+ transition:
864
+ border-color var(--duration-fast),
865
+ background var(--duration-fast),
866
+ color var(--duration-fast);
867
+ white-space: nowrap;
366
868
  }
367
869
 
368
- .expand-btn svg {
369
- transition: transform var(--duration-base) var(--ease-out);
870
+ .dropdown-trigger:hover:not(:disabled) {
871
+ border-color: color-mix(in srgb, var(--text-muted) 50%, var(--border));
872
+ }
873
+ .dropdown-trigger.open {
874
+ border-color: var(--accent);
875
+ background: var(--accent-soft);
876
+ color: var(--accent);
877
+ }
878
+ .dropdown-trigger.has-remote {
879
+ border-color: var(--accent);
880
+ color: var(--accent);
881
+ background: var(--accent-soft);
882
+ }
883
+ .dropdown-trigger:disabled {
884
+ opacity: 0.45;
885
+ cursor: default;
370
886
  }
371
887
 
372
- .expand-btn svg.rotated {
888
+ .trigger-chevron {
889
+ transition: transform 0.18s var(--ease-out);
890
+ flex-shrink: 0;
891
+ }
892
+ .trigger-chevron.open {
373
893
  transform: rotate(180deg);
374
894
  }
375
895
 
376
- /* ── Body ── */
377
- .body {
378
- padding: 0.875rem 1.5rem 1.125rem;
379
- border-top: 1px solid var(--border);
896
+ .dropdown-menu {
897
+ position: absolute;
898
+ bottom: calc(100% + 8px);
899
+ left: 0;
900
+ min-width: 130px;
901
+ background: var(--bg-elevated);
902
+ border: 1px solid var(--border);
903
+ border-radius: var(--radius-md);
904
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.13);
905
+ padding: 0.3rem;
906
+ z-index: 300;
380
907
  display: flex;
381
908
  flex-direction: column;
382
- gap: 0.75rem;
909
+ gap: 1px;
383
910
  }
384
911
 
385
- .controls {
912
+ .dropdown-item {
386
913
  display: flex;
387
914
  align-items: center;
388
- gap: 0.625rem;
915
+ gap: 0.5rem;
916
+ width: 100%;
917
+ padding: 0.35rem 0.5rem;
918
+ border: none;
919
+ border-radius: var(--radius-sm);
920
+ background: transparent;
921
+ font-family: var(--font-body);
922
+ font-size: 0.8125rem;
923
+ color: var(--text);
924
+ cursor: pointer;
925
+ text-align: left;
926
+ transition: background var(--duration-fast);
389
927
  }
390
928
 
391
- .run-input {
392
- flex: 1;
929
+ .dropdown-item:hover {
930
+ background: var(--bg-subtle);
931
+ }
932
+ .dropdown-item.active {
933
+ color: var(--accent);
393
934
  }
394
935
 
395
- .workers-control {
936
+ .runners-menu {
937
+ min-width: 160px;
938
+ }
939
+
940
+ .runner-option {
396
941
  display: flex;
397
- gap: 2px;
398
- background: var(--bg-subtle);
399
- border: 1px solid var(--border);
942
+ align-items: center;
943
+ gap: 0.5rem;
944
+ padding: 0.35rem 0.5rem;
400
945
  border-radius: var(--radius-sm);
401
- padding: 2px;
946
+ cursor: pointer;
947
+ font-size: 0.8125rem;
948
+ color: var(--text);
949
+ transition: background var(--duration-fast);
950
+ }
951
+
952
+ .runner-option:hover {
953
+ background: var(--bg-subtle);
954
+ }
955
+ .runner-option input[type='checkbox'] {
956
+ accent-color: var(--accent);
957
+ width: 13px;
958
+ height: 13px;
959
+ cursor: pointer;
960
+ flex-shrink: 0;
961
+ }
962
+ .runner-dot {
963
+ width: 6px;
964
+ height: 6px;
965
+ border-radius: 50%;
402
966
  flex-shrink: 0;
403
967
  }
968
+ .runner-dot.built-in {
969
+ background: var(--accent);
970
+ }
971
+ .runner-dot.remote {
972
+ background: var(--pass);
973
+ }
404
974
 
405
- .worker-btn {
406
- font-family: var(--font-body);
407
- font-size: 0.75rem;
408
- font-weight: 400;
409
- min-width: 2rem;
410
- padding: 0.2rem 0.5rem;
975
+ /* Run button */
976
+ .run-btn {
977
+ display: inline-flex;
978
+ align-items: center;
979
+ gap: 0.4rem;
980
+ height: 30px;
981
+ padding: 0 0.875rem;
982
+ background: var(--accent);
983
+ color: white;
411
984
  border: none;
412
- border-radius: 4px;
985
+ border-radius: var(--radius-sm);
986
+ font-family: var(--font-body);
987
+ font-size: 0.8125rem;
988
+ font-weight: 500;
989
+ cursor: pointer;
990
+ transition: opacity var(--duration-fast);
991
+ flex-shrink: 0;
992
+ }
993
+
994
+ .run-btn:hover:not(:disabled) {
995
+ opacity: 0.88;
996
+ }
997
+ .run-btn:disabled,
998
+ .run-btn.is-running {
999
+ opacity: 0.6;
1000
+ cursor: default;
1001
+ }
1002
+
1003
+ .run-spinner {
1004
+ width: 10px;
1005
+ height: 10px;
1006
+ border: 1.5px solid rgba(255, 255, 255, 0.35);
1007
+ border-top-color: white;
1008
+ border-radius: 50%;
1009
+ animation: spin 0.65s linear infinite;
1010
+ flex-shrink: 0;
1011
+ }
1012
+
1013
+ @keyframes spin {
1014
+ to {
1015
+ transform: rotate(360deg);
1016
+ }
1017
+ }
1018
+
1019
+ /* Expand button */
1020
+ .expand-btn {
1021
+ display: flex;
1022
+ align-items: center;
1023
+ justify-content: center;
1024
+ width: 28px;
1025
+ height: 28px;
1026
+ border: 1px solid var(--border);
1027
+ border-radius: var(--radius-sm);
413
1028
  background: transparent;
414
1029
  color: var(--text-muted);
415
1030
  cursor: pointer;
416
1031
  transition:
417
1032
  background var(--duration-fast),
418
- color var(--duration-fast);
1033
+ color var(--duration-fast),
1034
+ border-color var(--duration-fast);
1035
+ flex-shrink: 0;
419
1036
  }
420
1037
 
421
- .worker-btn:hover:not(.active) {
422
- background: var(--bg-elevated);
1038
+ .expand-btn:hover {
1039
+ background: var(--bg-subtle);
423
1040
  color: var(--text);
1041
+ border-color: var(--text-muted);
424
1042
  }
425
-
426
- .worker-btn.active {
427
- background: var(--bg-elevated);
428
- color: var(--accent);
429
- font-weight: 500;
430
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
1043
+ .expand-btn svg {
1044
+ transition: transform 0.2s var(--ease-out);
431
1045
  }
432
-
433
- /* ── Main row: terminal + sidebar ── */
434
- .main-row {
435
- display: flex;
436
- gap: 1rem;
437
- align-items: flex-start;
1046
+ .expand-btn svg.flipped {
1047
+ transform: rotate(180deg);
438
1048
  }
439
1049
 
440
- .terminal-col {
441
- flex: 1;
442
- min-width: 0;
1050
+ /* ── Expanded body ── */
1051
+ .body {
1052
+ border-top: 1px solid var(--border);
1053
+ padding: 0.625rem 1.25rem;
443
1054
  display: flex;
444
1055
  flex-direction: column;
445
- gap: 0.5rem;
446
- /* Constrain terminal height via the Terminal component's wrapper */
447
- --terminal-max-height: 140px;
1056
+ gap: 0.375rem;
1057
+ min-height: 72px;
448
1058
  }
449
1059
 
450
- .report-row {
1060
+ /* Active run card */
1061
+ .run-card {
451
1062
  display: flex;
1063
+ align-items: center;
1064
+ gap: 0.75rem;
1065
+ padding: 0.625rem 0.875rem;
1066
+ border: 1px solid var(--border);
1067
+ border-radius: var(--radius-md);
1068
+ background: var(--bg-subtle);
1069
+ text-decoration: none;
1070
+ color: inherit;
1071
+ transition:
1072
+ background var(--duration-fast),
1073
+ border-color var(--duration-fast);
452
1074
  }
453
1075
 
454
- /* ── Sidebar ── */
455
- .sidebar {
456
- width: 260px;
1076
+ a.run-card:hover {
1077
+ background: var(--bg-elevated);
1078
+ border-color: var(--accent);
1079
+ }
1080
+
1081
+ .run-card.active-run {
1082
+ border-left: 3px solid var(--accent);
1083
+ }
1084
+ .run-card.cron-run {
1085
+ border-left: 3px solid var(--warn);
1086
+ }
1087
+
1088
+ .run-card-dot {
1089
+ width: 8px;
1090
+ height: 8px;
1091
+ border-radius: 50%;
457
1092
  flex-shrink: 0;
458
- display: flex;
459
- flex-direction: column;
460
- gap: 1rem;
461
- padding-left: 1.25rem;
462
- border-left: 1px solid var(--border);
463
1093
  }
464
1094
 
465
- .sidebar-section {
1095
+ .pulse-accent {
1096
+ background: var(--accent);
1097
+ animation: dotPulse 1.6s ease-in-out infinite;
1098
+ }
1099
+
1100
+ .pulse-pass {
1101
+ background: var(--pass);
1102
+ animation: dotPulse 1.6s ease-in-out infinite;
1103
+ }
1104
+
1105
+ .run-card-info {
466
1106
  display: flex;
467
1107
  flex-direction: column;
468
- gap: 0.5rem;
1108
+ gap: 0.1rem;
1109
+ flex: 1;
1110
+ min-width: 0;
1111
+ }
1112
+
1113
+ .run-card-label {
1114
+ font-size: 0.8125rem;
1115
+ font-weight: 500;
1116
+ color: var(--text);
1117
+ }
1118
+
1119
+ .run-card-meta {
1120
+ font-size: 0.72rem;
1121
+ font-family: 'JetBrains Mono', monospace;
1122
+ color: var(--text-muted);
1123
+ white-space: nowrap;
1124
+ overflow: hidden;
1125
+ text-overflow: ellipsis;
469
1126
  }
470
1127
 
471
- .sidebar-label {
472
- font-size: 0.65rem;
1128
+ .meta-dot {
1129
+ opacity: 0.4;
1130
+ margin: 0 0.15rem;
1131
+ }
1132
+
1133
+ .run-card-badge {
1134
+ font-size: 0.62rem;
473
1135
  font-weight: 600;
474
- letter-spacing: 0.09em;
1136
+ letter-spacing: 0.07em;
475
1137
  text-transform: uppercase;
476
- color: var(--text-muted);
1138
+ color: var(--accent);
1139
+ background: var(--accent-soft);
1140
+ padding: 0.1rem 0.4rem;
1141
+ border-radius: 100px;
1142
+ flex-shrink: 0;
477
1143
  }
478
1144
 
479
- .sidebar-empty {
480
- font-size: 0.75rem;
1145
+ .run-card-badge.cron-badge {
1146
+ color: var(--warn);
1147
+ background: var(--warn-soft);
1148
+ }
1149
+
1150
+ .run-card-arrow {
481
1151
  color: var(--text-muted);
482
- font-style: italic;
1152
+ flex-shrink: 0;
1153
+ transition:
1154
+ transform var(--duration-fast) var(--ease-out),
1155
+ color var(--duration-fast);
483
1156
  }
484
1157
 
485
- .cron-list {
486
- list-style: none;
487
- padding: 0;
488
- margin: 0;
489
- display: flex;
490
- flex-direction: column;
491
- gap: 0.375rem;
1158
+ a.run-card:hover .run-card-arrow {
1159
+ transform: translateX(3px);
1160
+ color: var(--accent);
492
1161
  }
493
1162
 
494
- .cron-item {
1163
+ /* Empty state */
1164
+ .empty-runs {
495
1165
  display: flex;
496
1166
  align-items: center;
497
- gap: 0.375rem;
498
- }
499
-
500
- .cron-dot {
501
- width: 7px;
502
- height: 7px;
503
- border-radius: 50%;
504
- background: var(--pass);
505
- flex-shrink: 0;
506
- animation: statusPulse 1.6s ease-in-out infinite;
1167
+ justify-content: center;
1168
+ gap: 0.5rem;
1169
+ padding: 0.5rem 0;
1170
+ color: var(--text-muted);
1171
+ font-size: 0.8125rem;
507
1172
  }
508
1173
 
509
- .cron-name {
510
- font-size: 0.75rem;
511
- font-family: 'JetBrains Mono', monospace;
512
- color: var(--text);
513
- white-space: nowrap;
514
- overflow: hidden;
515
- text-overflow: ellipsis;
1174
+ .empty-icon {
1175
+ opacity: 0.4;
516
1176
  }
517
1177
  </style>