plum-e2e 1.2.4 → 1.3.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 (62) hide show
  1. package/CLAUDE.md +201 -0
  2. package/README.md +237 -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 +7 -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 +132 -19
  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 +304 -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
@@ -17,12 +17,23 @@
17
17
 
18
18
  <script>
19
19
  import { onMount } from 'svelte';
20
- import { fly } from 'svelte/transition';
21
- import { fetchCronJobs, saveCronJob, deleteCronJob } from '$lib/api/schedules';
20
+ import {
21
+ fetchCronJobs,
22
+ saveCronJob,
23
+ deleteCronJob,
24
+ runCronJobNow,
25
+ toggleCronJob
26
+ } from '$lib/api/schedules';
27
+ import { fetchRunners } from '$lib/api/runners';
22
28
  import { activeCronJobs } from '$lib/stores/runner';
29
+ import { BROWSERS, WORKER_OPTIONS, TOAST_TIMEOUT_MS } from '$lib/constants';
30
+ import { stagger } from '$lib/utils/format';
23
31
  import Button from '$lib/components/ui/Button.svelte';
24
32
  import Badge from '$lib/components/ui/Badge.svelte';
25
33
  import Modal from '$lib/components/ui/Modal.svelte';
34
+ import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
35
+ import Toast from '$lib/components/ui/Toast.svelte';
36
+ import EmptyState from '$lib/components/ui/EmptyState.svelte';
26
37
 
27
38
  const CRON_REGEX =
28
39
  /^(\*|([0-5]?[0-9])|\*\/[0-9]+) (\*|([01]?[0-9]|2[0-3])) (\*|([01]?[0-9]|3[01])) (\*|([1-9]|1[0-2])) (\*|[0-6](-[0-6])?)$/;
@@ -38,6 +49,7 @@
38
49
  ];
39
50
 
40
51
  let cronJobs = [];
52
+ let availableRunners = [];
41
53
  let toast = null;
42
54
 
43
55
  let modalOpen = false;
@@ -46,9 +58,14 @@
46
58
  let editTaskName = '';
47
59
  let taskToDelete = '';
48
60
 
49
- const WORKER_OPTIONS = [1, 2, 4, 8];
50
-
51
- let form = { taskName: '', cronExpression: '', tags: '', workers: 1 };
61
+ let form = {
62
+ taskName: '',
63
+ cronExpression: '',
64
+ tags: '',
65
+ workers: 1,
66
+ browser: 'chromium',
67
+ runnerIds: ['built-in']
68
+ };
52
69
  let selectedSchedule = '';
53
70
  let useCustomCron = false;
54
71
  let formError = '';
@@ -58,7 +75,6 @@
58
75
  const parts = expr.trim().split(/\s+/);
59
76
  if (parts.length !== 5) return '';
60
77
  const [min, hour, dom, month, dow] = parts;
61
-
62
78
  const fmt = (h, m) => {
63
79
  const ap = h >= 12 ? 'PM' : 'AM';
64
80
  return `${h % 12 || 12}:${String(m).padStart(2, '0')} ${ap}`;
@@ -89,7 +105,7 @@
89
105
 
90
106
  function showToast(type, message) {
91
107
  toast = { type, message };
92
- setTimeout(() => (toast = null), 4000);
108
+ setTimeout(() => (toast = null), TOAST_TIMEOUT_MS);
93
109
  }
94
110
 
95
111
  function handleScheduleChange(e) {
@@ -102,10 +118,23 @@
102
118
  }
103
119
  }
104
120
 
121
+ function toggleFormRunner(id) {
122
+ const current = form.runnerIds;
123
+ if (current.includes(id) && current.length === 1) return;
124
+ form.runnerIds = current.includes(id) ? current.filter((r) => r !== id) : [...current, id];
125
+ }
126
+
105
127
  function openAddModal() {
106
128
  isEditing = false;
107
129
  editTaskName = '';
108
- form = { taskName: '', cronExpression: '', tags: '', workers: 1 };
130
+ form = {
131
+ taskName: '',
132
+ cronExpression: '',
133
+ tags: '',
134
+ workers: 1,
135
+ browser: 'chromium',
136
+ runnerIds: ['built-in']
137
+ };
109
138
  selectedSchedule = '';
110
139
  useCustomCron = false;
111
140
  formError = '';
@@ -115,11 +144,14 @@
115
144
  function openEditModal(job) {
116
145
  isEditing = true;
117
146
  editTaskName = job.taskName;
147
+ const storedIds = job.runnerIds ? job.runnerIds.split(',').map((s) => s.trim()) : ['built-in'];
118
148
  form = {
119
149
  taskName: job.taskName,
120
150
  cronExpression: job.cronExpression,
121
151
  tags: job.tags,
122
- workers: job.workers ?? 1
152
+ workers: job.workers ?? 1,
153
+ browser: job.browser ?? 'chromium',
154
+ runnerIds: storedIds
123
155
  };
124
156
  const isPreset = scheduleOptions.some((o) => o.value === job.cronExpression);
125
157
  useCustomCron = !isPreset;
@@ -128,11 +160,6 @@
128
160
  modalOpen = true;
129
161
  }
130
162
 
131
- function openDeleteModal(taskName) {
132
- taskToDelete = taskName;
133
- deleteModalOpen = true;
134
- }
135
-
136
163
  async function handleSave() {
137
164
  if (!form.taskName || !form.cronExpression || !form.tags) {
138
165
  formError = 'All fields are required.';
@@ -157,6 +184,26 @@
157
184
  }
158
185
  }
159
186
 
187
+ async function handleToggle(job) {
188
+ const next = !job.enabled;
189
+ cronJobs = cronJobs.map((j) => (j.taskName === job.taskName ? { ...j, enabled: next } : j));
190
+ try {
191
+ await toggleCronJob(job.taskName, next);
192
+ } catch {
193
+ cronJobs = cronJobs.map((j) => (j.taskName === job.taskName ? { ...j, enabled: !next } : j));
194
+ showToast('error', 'Could not update schedule.');
195
+ }
196
+ }
197
+
198
+ async function handleRunNow(taskName) {
199
+ try {
200
+ await runCronJobNow(taskName);
201
+ showToast('success', `Started: ${taskName}`);
202
+ } catch {
203
+ showToast('error', 'Could not trigger run.');
204
+ }
205
+ }
206
+
160
207
  async function handleDelete() {
161
208
  try {
162
209
  const data = await deleteCronJob(taskToDelete);
@@ -179,6 +226,9 @@
179
226
 
180
227
  onMount(async () => {
181
228
  cronJobs = await fetchCronJobs();
229
+ try {
230
+ availableRunners = await fetchRunners();
231
+ } catch {}
182
232
  });
183
233
  </script>
184
234
 
@@ -190,24 +240,19 @@
190
240
  <div class="field">
191
241
  <div class="field-label">
192
242
  <span>Task Name</span>
193
- <span class="field-hint">
194
- {isEditing ? 'Name is the ID — cannot be changed' : 'Use a unique, meaningful name'}
195
- </span>
243
+ <span class="field-hint">Use a unique, meaningful name</span>
196
244
  </div>
197
245
  <input
198
246
  type="text"
199
247
  class="field-input"
200
248
  bind:value={form.taskName}
201
249
  placeholder="nightly-login-suite"
202
- disabled={isEditing}
203
250
  required
204
251
  />
205
252
  </div>
206
253
 
207
254
  <div class="field">
208
- <div class="field-label">
209
- <span>Schedule</span>
210
- </div>
255
+ <div class="field-label"><span>Schedule</span></div>
211
256
  <select
212
257
  class="field-input"
213
258
  bind:value={selectedSchedule}
@@ -255,7 +300,7 @@
255
300
 
256
301
  <div class="field">
257
302
  <div class="field-label">
258
- <span>Runners</span>
303
+ <span>Workers</span>
259
304
  <span class="field-hint">Parallel workers for this job</span>
260
305
  </div>
261
306
  <div class="seg-control">
@@ -267,34 +312,74 @@
267
312
  on:click={() => (form.workers = n)}
268
313
  >
269
314
  <span class="seg-num">{n}</span>
270
- <span class="seg-label">{n === 1 ? 'runner' : 'runners'}</span>
315
+ <span class="seg-label">{n === 1 ? 'worker' : 'workers'}</span>
271
316
  </button>
272
317
  {/each}
273
318
  </div>
274
319
  </div>
275
320
 
321
+ <div class="field">
322
+ <div class="field-label"><span>Browser</span></div>
323
+ <div class="seg-control">
324
+ {#each BROWSERS as b}
325
+ <button
326
+ type="button"
327
+ class="seg-btn"
328
+ class:active={form.browser === b.id}
329
+ on:click={() => (form.browser = b.id)}
330
+ >
331
+ <span class="seg-num">{b.label}</span>
332
+ </button>
333
+ {/each}
334
+ </div>
335
+ </div>
336
+
337
+ <div class="field">
338
+ <div class="field-label">
339
+ <span>Runners</span>
340
+ <span class="field-hint">Select one or more nodes</span>
341
+ </div>
342
+ <div class="runner-checks">
343
+ <label class="runner-check-option">
344
+ <input
345
+ type="checkbox"
346
+ checked={form.runnerIds.includes('built-in')}
347
+ on:change={() => toggleFormRunner('built-in')}
348
+ />
349
+ <span class="runner-check-dot built-in"></span>
350
+ <span>Built-in</span>
351
+ <span class="runner-check-hint">this server</span>
352
+ </label>
353
+ {#each availableRunners as r}
354
+ <label class="runner-check-option">
355
+ <input
356
+ type="checkbox"
357
+ checked={form.runnerIds.includes(r.id)}
358
+ on:change={() => toggleFormRunner(r.id)}
359
+ />
360
+ <span class="runner-check-dot remote"></span>
361
+ <span>{r.name}</span>
362
+ <span class="runner-check-hint">{r.url}</span>
363
+ </label>
364
+ {/each}
365
+ </div>
366
+ </div>
367
+
276
368
  {#if formError}
277
369
  <p class="form-error">{formError}</p>
278
370
  {/if}
279
371
 
280
- <Button type="submit" size="md">
281
- {isEditing ? 'Save Changes' : 'Add Cron Job'}
282
- </Button>
372
+ <Button type="submit" size="md">{isEditing ? 'Save Changes' : 'Add Cron Job'}</Button>
283
373
  </form>
284
374
  </Modal>
285
375
 
286
- <!-- Delete confirmation modal -->
287
- <Modal bind:open={deleteModalOpen} title="Delete Cron Job">
288
- <p class="confirm-text">
289
- Are you sure you want to delete <strong>{taskToDelete}</strong>? This cannot be undone.
290
- </p>
291
- <div class="confirm-actions">
292
- <Button variant="danger" on:click={handleDelete}>Delete</Button>
293
- <Button variant="ghost" on:click={() => (deleteModalOpen = false)}>Cancel</Button>
294
- </div>
295
- </Modal>
376
+ <!-- Delete confirmation -->
377
+ <ConfirmModal bind:open={deleteModalOpen} title="Delete Cron Job" on:confirm={handleDelete}>
378
+ Are you sure you want to delete <strong>{taskToDelete}</strong>? This cannot be undone.
379
+ </ConfirmModal>
380
+
381
+ <Toast {toast} />
296
382
 
297
- <!-- Page -->
298
383
  <div class="page-header">
299
384
  <div class="header-row">
300
385
  <div>
@@ -305,51 +390,86 @@
305
390
  </div>
306
391
  </div>
307
392
 
308
- {#if toast}
309
- <div class="toast alert alert-{toast.type}" transition:fly={{ y: -8, duration: 240 }}>
310
- {toast.message}
311
- </div>
312
- {/if}
313
-
314
393
  <div class="card" style="padding: 0; overflow: hidden;">
315
394
  {#if cronJobs.length === 0}
316
- <p class="empty">No scheduled tests yet. Create one to get started.</p>
395
+ <EmptyState message="No scheduled tests yet. Create one to get started." size="sm" />
317
396
  {:else}
318
397
  <div class="table-wrap">
319
398
  <table class="data-table">
320
399
  <thead>
321
400
  <tr>
401
+ <th class="th-toggle"></th>
402
+ <th class="th-run"></th>
322
403
  <th>Name</th>
323
404
  <th>Schedule</th>
324
405
  <th>Tags</th>
325
- <th>Runners</th>
406
+ <th>Workers</th>
407
+ <th>Browser</th>
408
+ <th>Runner</th>
326
409
  <th>Actions</th>
327
410
  </tr>
328
411
  </thead>
329
412
  <tbody>
330
413
  {#each cronJobs as job, i}
331
- <tr style="animation-delay: {i * 45}ms" class="job-row">
414
+ {@const ids = job.runnerIds
415
+ ? job.runnerIds.split(',').map((s) => s.trim())
416
+ : ['built-in']}
417
+ <tr style={stagger(i)} class="job-row" class:disabled={!job.enabled}>
418
+ <td class="td-toggle">
419
+ <button
420
+ class="toggle-btn"
421
+ class:on={job.enabled}
422
+ on:click={() => handleToggle(job)}
423
+ title={job.enabled ? 'Disable schedule' : 'Enable schedule'}
424
+ aria-label={job.enabled ? 'Disable schedule' : 'Enable schedule'}
425
+ aria-pressed={job.enabled}
426
+ >
427
+ <span class="toggle-knob"></span>
428
+ </button>
429
+ </td>
430
+ <td class="td-run">
431
+ <button
432
+ class="run-icon-btn"
433
+ on:click={() => handleRunNow(job.taskName)}
434
+ title="Run {job.taskName} now"
435
+ >
436
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" stroke="none">
437
+ <polygon points="5,3 19,12 5,21" />
438
+ </svg>
439
+ </button>
440
+ </td>
332
441
  <td class="job-name">
333
442
  {#if $activeCronJobs[job.taskName]}
334
443
  <span class="running-dot" title="Running now"></span>
335
444
  {/if}
336
445
  {job.taskName}
337
446
  </td>
447
+ <td><Badge variant="schedule">{cronLabel(job.cronExpression)}</Badge></td>
448
+ <td><span class="tag-text">{job.tags}</span></td>
338
449
  <td>
339
- <Badge variant="schedule">{cronLabel(job.cronExpression)}</Badge>
340
- </td>
341
- <td>
342
- <span class="tag-text">{job.tags}</span>
450
+ <span class="workers-badge" class:multi={job.workers > 1}>×{job.workers}</span>
343
451
  </td>
452
+ <td><span class="browser-badge">{job.browser ?? 'chromium'}</span></td>
344
453
  <td>
345
- <span class="workers-badge" class:multi={job.workers > 1}>
346
- ×{job.workers}
454
+ <span class="runner-badge" class:multi-node={ids.length > 1}>
455
+ {#if ids.length === 1 && ids[0] === 'built-in'}
456
+ built-in
457
+ {:else if ids.length === 1}
458
+ {availableRunners.find((r) => r.id === ids[0])?.name ?? ids[0]}
459
+ {:else}
460
+ {ids.length} nodes
461
+ {/if}
347
462
  </span>
348
463
  </td>
349
464
  <td class="actions-cell">
350
465
  <Button variant="ghost" size="sm" on:click={() => openEditModal(job)}>Edit</Button>
351
- <Button variant="danger" size="sm" on:click={() => openDeleteModal(job.taskName)}
352
- >Delete</Button
466
+ <Button
467
+ variant="danger"
468
+ size="sm"
469
+ on:click={() => {
470
+ taskToDelete = job.taskName;
471
+ deleteModalOpen = true;
472
+ }}>Delete</Button
353
473
  >
354
474
  </td>
355
475
  </tr>
@@ -388,11 +508,6 @@
388
508
  animation: fadeUp 0.32s var(--ease-out) both;
389
509
  }
390
510
 
391
- .toast {
392
- margin-bottom: 1.25rem;
393
- border-radius: var(--radius-md);
394
- }
395
-
396
511
  .table-wrap {
397
512
  overflow-x: auto;
398
513
  }
@@ -433,48 +548,104 @@
433
548
  color: var(--text-muted);
434
549
  }
435
550
 
436
- .actions-cell {
437
- display: flex;
438
- gap: 0.375rem;
551
+ .th-toggle,
552
+ .td-toggle {
553
+ width: 44px;
554
+ padding-right: 0;
555
+ text-align: center;
556
+ padding-top: 20px;
439
557
  }
440
558
 
441
- .empty {
442
- padding: 3rem 1.5rem;
443
- color: var(--text-muted);
444
- font-size: 0.9375rem;
559
+ .th-run,
560
+ .td-run {
561
+ width: 36px;
562
+ padding-right: 0;
445
563
  text-align: center;
446
564
  }
447
565
 
448
- /* Modal form */
449
- .modal-form {
450
- display: flex;
451
- flex-direction: column;
452
- gap: 1rem;
566
+ /* Toggle switch */
567
+ .toggle-btn {
568
+ position: relative;
569
+ display: inline-flex;
570
+ align-items: center;
571
+ width: 32px;
572
+ height: 18px;
573
+ border-radius: 9px;
574
+ border: none;
575
+ background: var(--border);
576
+ cursor: pointer;
577
+ transition: background var(--duration-fast);
578
+ flex-shrink: 0;
579
+ padding: 0;
453
580
  }
454
581
 
455
- .form-error {
456
- font-size: 0.8125rem;
457
- color: var(--fail);
582
+ .toggle-btn.on {
583
+ background: var(--pass);
458
584
  }
459
585
 
460
- .confirm-text {
461
- font-size: 0.9375rem;
586
+ .toggle-knob {
587
+ position: absolute;
588
+ left: 2px;
589
+ width: 14px;
590
+ height: 14px;
591
+ border-radius: 50%;
592
+ background: #fff;
593
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
594
+ transition: left var(--duration-fast);
595
+ }
596
+
597
+ .toggle-btn.on .toggle-knob {
598
+ left: 16px;
599
+ }
600
+
601
+ .job-row.disabled {
602
+ opacity: 0.45;
603
+ }
604
+ .job-row.disabled .toggle-btn {
605
+ opacity: 1;
606
+ }
607
+
608
+ .run-icon-btn {
609
+ display: flex;
610
+ align-items: center;
611
+ justify-content: center;
612
+ width: 24px;
613
+ height: 24px;
614
+ border-radius: 50%;
615
+ border: 1px solid var(--border);
616
+ background: transparent;
462
617
  color: var(--text-muted);
463
- line-height: 1.6;
618
+ cursor: pointer;
619
+ flex-shrink: 0;
620
+ transition:
621
+ background var(--duration-fast),
622
+ color var(--duration-fast),
623
+ border-color var(--duration-fast);
464
624
  }
465
625
 
466
- .confirm-text strong {
467
- color: var(--text);
468
- font-weight: 500;
626
+ .run-icon-btn:hover {
627
+ background: var(--accent);
628
+ color: #fff;
629
+ border-color: var(--accent);
469
630
  }
470
631
 
471
- .confirm-actions {
632
+ .actions-cell {
472
633
  display: flex;
473
- gap: 0.625rem;
474
- padding-top: 0.25rem;
634
+ gap: 0.375rem;
635
+ }
636
+
637
+ /* Modal form */
638
+ .modal-form {
639
+ display: flex;
640
+ flex-direction: column;
641
+ gap: 1rem;
642
+ }
643
+
644
+ .form-error {
645
+ font-size: 0.8125rem;
646
+ color: var(--fail);
475
647
  }
476
648
 
477
- /* Custom cron input */
478
649
  .cron-input {
479
650
  margin-top: 0.5rem;
480
651
  font-family: 'JetBrains Mono', monospace;
@@ -534,22 +705,19 @@
534
705
  .seg-btn:last-child {
535
706
  border-right: none;
536
707
  }
537
-
538
708
  .seg-btn:hover {
539
709
  background: var(--bg-subtle);
540
710
  color: var(--text);
541
711
  }
542
-
543
712
  .seg-btn.active {
544
713
  background: var(--accent);
545
714
  color: #fff;
546
715
  }
547
-
548
716
  .seg-btn.active .seg-label {
549
717
  opacity: 0.85;
550
718
  }
551
719
 
552
- /* Workers badge in table */
720
+ /* Table badges */
553
721
  .workers-badge {
554
722
  font-family: 'JetBrains Mono', monospace;
555
723
  font-size: 0.75rem;
@@ -560,4 +728,76 @@
560
728
  color: var(--accent);
561
729
  font-weight: 500;
562
730
  }
731
+
732
+ .browser-badge,
733
+ .runner-badge {
734
+ font-family: 'JetBrains Mono', monospace;
735
+ font-size: 0.72rem;
736
+ color: var(--text-muted);
737
+ }
738
+
739
+ .runner-badge.multi-node {
740
+ color: var(--node);
741
+ font-weight: 500;
742
+ }
743
+
744
+ /* Runner checkboxes in modal */
745
+ .runner-checks {
746
+ display: flex;
747
+ flex-direction: column;
748
+ gap: 0.25rem;
749
+ border: 1px solid var(--border);
750
+ border-radius: var(--radius-sm);
751
+ padding: 0.375rem 0.5rem;
752
+ background: var(--bg-subtle);
753
+ }
754
+
755
+ .runner-check-option {
756
+ display: flex;
757
+ align-items: center;
758
+ gap: 0.5rem;
759
+ padding: 0.3rem 0.375rem;
760
+ border-radius: var(--radius-sm);
761
+ cursor: pointer;
762
+ font-size: 0.8125rem;
763
+ color: var(--text);
764
+ transition: background var(--duration-fast);
765
+ }
766
+
767
+ .runner-check-option:hover {
768
+ background: var(--bg-elevated);
769
+ }
770
+
771
+ .runner-check-option input[type='checkbox'] {
772
+ accent-color: var(--accent);
773
+ width: 13px;
774
+ height: 13px;
775
+ flex-shrink: 0;
776
+ cursor: pointer;
777
+ }
778
+
779
+ .runner-check-dot {
780
+ width: 6px;
781
+ height: 6px;
782
+ border-radius: 50%;
783
+ flex-shrink: 0;
784
+ }
785
+
786
+ .runner-check-dot.built-in {
787
+ background: var(--accent);
788
+ }
789
+ .runner-check-dot.remote {
790
+ background: var(--node);
791
+ }
792
+
793
+ .runner-check-hint {
794
+ margin-left: auto;
795
+ font-size: 0.7rem;
796
+ font-family: 'JetBrains Mono', monospace;
797
+ color: var(--text-muted);
798
+ white-space: nowrap;
799
+ overflow: hidden;
800
+ text-overflow: ellipsis;
801
+ max-width: 180px;
802
+ }
563
803
  </style>