plum-e2e 1.3.6 → 2.1.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 (47) hide show
  1. package/README.md +61 -3
  2. package/backend/app.js +5 -0
  3. package/backend/config/scripts/create-test.mjs +172 -0
  4. package/backend/config/scripts/generate-report.js +2 -1
  5. package/backend/middleware/jwtAuth.js +33 -0
  6. package/backend/middleware/requireAdmin.js +25 -0
  7. package/backend/package.json +2 -0
  8. package/backend/prisma/migrations/20260618000000_add_test_repository/migration.sql +133 -0
  9. package/backend/prisma/migrations/20260618000001_add_user_roles/migration.sql +3 -0
  10. package/backend/prisma/migrations/20260618000002_drop_automated_tag/migration.sql +2 -0
  11. package/backend/prisma/migrations/20260618000003_entry_assignee/migration.sql +2 -0
  12. package/backend/prisma/schema.prisma +118 -10
  13. package/backend/routes/auth.routes.js +96 -0
  14. package/backend/routes/settings.routes.js +44 -8
  15. package/backend/routes/test-cases.routes.js +80 -0
  16. package/backend/routes/test-runs.routes.js +122 -0
  17. package/backend/routes/test-suites.routes.js +92 -0
  18. package/backend/routes/users.routes.js +67 -0
  19. package/backend/scripts/create-test.js +7 -6
  20. package/backend/services/reportService.js +96 -4
  21. package/backend/services/runnerService.js +16 -1
  22. package/backend/services/settingsService.js +18 -2
  23. package/backend/services/testCaseService.js +139 -0
  24. package/backend/services/testRunService.js +203 -0
  25. package/backend/services/testSuiteService.js +191 -0
  26. package/backend/services/userService.js +114 -0
  27. package/backend/websockets/socketHandler.js +19 -6
  28. package/bin/plum.js +105 -9
  29. package/frontend/src/lib/api/auth.js +69 -0
  30. package/frontend/src/lib/api/repository.js +256 -0
  31. package/frontend/src/lib/api/users.js +52 -0
  32. package/frontend/src/lib/components/layout/Nav.svelte +116 -4
  33. package/frontend/src/lib/components/layout/RunnerPanel.svelte +243 -29
  34. package/frontend/src/lib/components/ui/Modal.svelte +8 -1
  35. package/frontend/src/lib/constants.js +2 -0
  36. package/frontend/src/lib/stores/auth.js +60 -0
  37. package/frontend/src/lib/stores/runner.js +9 -2
  38. package/frontend/src/routes/+layout.svelte +32 -4
  39. package/frontend/src/routes/+page.svelte +1 -1
  40. package/frontend/src/routes/login/+page.svelte +209 -0
  41. package/frontend/src/routes/scheduled-tests/+page.svelte +3 -1
  42. package/frontend/src/routes/settings/+page.svelte +586 -5
  43. package/frontend/src/routes/setup/+page.svelte +249 -0
  44. package/frontend/src/routes/test-repository/+page.svelte +1379 -0
  45. package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +1549 -0
  46. package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +1490 -0
  47. package/package.json +1 -1
@@ -0,0 +1,1549 @@
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, onDestroy } from 'svelte';
20
+ import { page } from '$app/stores';
21
+ import { fly } from 'svelte/transition';
22
+ import {
23
+ fetchRun,
24
+ updateRun,
25
+ fetchAllSuitesWithCases,
26
+ recordEntryResult,
27
+ assignEntry,
28
+ fetchMembers
29
+ } from '$lib/api/repository';
30
+ import { runsVersion } from '$lib/stores/runner';
31
+ import { auth } from '$lib/stores/auth';
32
+ import Button from '$lib/components/ui/Button.svelte';
33
+ import Toast from '$lib/components/ui/Toast.svelte';
34
+ import { TOAST_TIMEOUT_MS } from '$lib/constants';
35
+
36
+ const runId = $page.params.id;
37
+
38
+ /** @type {'build' | 'execute'} */
39
+ let mode = 'build';
40
+
41
+ let run = null;
42
+ let suites = [];
43
+ let members = [];
44
+ let loading = true;
45
+ let toast = null;
46
+ let saving = false;
47
+ let executing = false;
48
+ let assigning = null;
49
+
50
+ $: currentUserId = $auth?.user?.id ?? null;
51
+
52
+ let search = '';
53
+ let expandedSuites = new Set();
54
+
55
+ const PRIORITY_ORDER = { Critical: 0, High: 1, Medium: 2, Low: 3 };
56
+
57
+ let entriesSort = (() => {
58
+ try {
59
+ const v = sessionStorage.getItem('plum:run:entries:sort');
60
+ if (v) return JSON.parse(v);
61
+ } catch {}
62
+ return { by: 'order', order: 'asc' };
63
+ })();
64
+
65
+ $: sortedEntries = (() => {
66
+ if (!run) return [];
67
+ const list = [...run.entries];
68
+ const dir = entriesSort.order === 'asc' ? 1 : -1;
69
+ if (entriesSort.by === 'name')
70
+ return list.sort((a, b) => dir * a.case.title.localeCompare(b.case.title));
71
+ if (entriesSort.by === 'priority')
72
+ return list.sort(
73
+ (a, b) =>
74
+ dir * ((PRIORITY_ORDER[a.case.priority] ?? 99) - (PRIORITY_ORDER[b.case.priority] ?? 99))
75
+ );
76
+ if (entriesSort.by === 'status')
77
+ return list.sort((a, b) => dir * a.status.localeCompare(b.status));
78
+ if (entriesSort.by === 'suite')
79
+ return list.sort(
80
+ (a, b) => dir * (a.case.suite?.name ?? '').localeCompare(b.case.suite?.name ?? '')
81
+ );
82
+ return list.sort((a, b) => dir * (a.order - b.order));
83
+ })();
84
+
85
+ function applyEntriesSort(by) {
86
+ if (entriesSort.by === by) {
87
+ entriesSort.order = entriesSort.order === 'asc' ? 'desc' : 'asc';
88
+ } else {
89
+ entriesSort = { by, order: 'asc' };
90
+ }
91
+ try {
92
+ sessionStorage.setItem('plum:run:entries:sort', JSON.stringify(entriesSort));
93
+ } catch {}
94
+ }
95
+
96
+ let dragOver = false;
97
+ let dragEntryId = null;
98
+ let dragOverEntryId = null;
99
+
100
+ let executingEntryId = null;
101
+ let entryNote = '';
102
+ let expandedExecEntries = new Set();
103
+
104
+ function toggleExecSteps(entryId) {
105
+ if (expandedExecEntries.has(entryId)) expandedExecEntries.delete(entryId);
106
+ else expandedExecEntries.add(entryId);
107
+ expandedExecEntries = new Set(expandedExecEntries);
108
+ }
109
+
110
+ function showToast(type, message) {
111
+ toast = { type, message };
112
+ setTimeout(() => (toast = null), TOAST_TIMEOUT_MS);
113
+ }
114
+
115
+ let refreshInterval = null;
116
+
117
+ onMount(async () => {
118
+ try {
119
+ [run, suites, members] = await Promise.all([
120
+ fetchRun(runId),
121
+ fetchAllSuitesWithCases(),
122
+ fetchMembers()
123
+ ]);
124
+ expandedSuites = new Set(suites.map((s) => s.id));
125
+ if (run.status === 'in-progress') mode = 'execute';
126
+ } catch (e) {
127
+ showToast('error', 'Failed to load data');
128
+ } finally {
129
+ loading = false;
130
+ }
131
+ refreshInterval = setInterval(async () => {
132
+ try {
133
+ const fresh = await fetchRun(runId);
134
+ run = fresh;
135
+ } catch {}
136
+ }, 15000);
137
+ });
138
+
139
+ onDestroy(() => {
140
+ if (refreshInterval) clearInterval(refreshInterval);
141
+ });
142
+
143
+ async function handleAssignEntry(entryId, userId) {
144
+ assigning = entryId;
145
+ try {
146
+ const updated = await assignEntry(entryId, userId || null);
147
+ run = {
148
+ ...run,
149
+ entries: run.entries.map((e) =>
150
+ e.id === entryId
151
+ ? { ...e, assignedToId: updated.assignedToId, assignedTo: updated.assignedTo }
152
+ : e
153
+ )
154
+ };
155
+ } catch (e) {
156
+ showToast('error', e.message);
157
+ } finally {
158
+ assigning = null;
159
+ }
160
+ }
161
+
162
+ $: runCaseIds = new Set(run?.entries.map((e) => e.case.id) ?? []);
163
+
164
+ $: filteredSuites = suites
165
+ .map((suite) => ({
166
+ ...suite,
167
+ _totalCases: (suite.cases ?? []).length,
168
+ cases: (suite.cases ?? []).filter(
169
+ (tc) =>
170
+ !runCaseIds.has(tc.id) &&
171
+ (!search ||
172
+ tc.title.toLowerCase().includes(search.toLowerCase()) ||
173
+ tc.displayId.toLowerCase().includes(search.toLowerCase()))
174
+ )
175
+ }))
176
+ .filter(
177
+ (suite) =>
178
+ !search || suite.cases.length > 0 || suite.name.toLowerCase().includes(search.toLowerCase())
179
+ );
180
+
181
+ function addCase(tc) {
182
+ if (!run || runCaseIds.has(tc.id)) return;
183
+ const entry = {
184
+ id: `tmp-${tc.id}`,
185
+ order: run.entries.length,
186
+ status: 'pending',
187
+ notes: '',
188
+ executedAt: null,
189
+ executedBy: null,
190
+ case: tc
191
+ };
192
+ run = { ...run, entries: [...run.entries, entry] };
193
+ }
194
+
195
+ function removeEntry(entryId) {
196
+ run = { ...run, entries: run.entries.filter((e) => e.id !== entryId) };
197
+ }
198
+
199
+ async function handleSaveRun() {
200
+ saving = true;
201
+ try {
202
+ const caseIds = run.entries.map((e) => e.case.id);
203
+ const updated = await updateRun(runId, { caseIds });
204
+ run = await fetchRun(runId);
205
+ showToast('success', 'Run saved.');
206
+ } catch (e) {
207
+ showToast('error', e.message);
208
+ } finally {
209
+ saving = false;
210
+ }
211
+ }
212
+
213
+ async function handleStartExecution() {
214
+ await handleSaveRun();
215
+ await updateRun(runId, { status: 'in-progress' });
216
+ run = { ...run, status: 'in-progress' };
217
+ mode = 'execute';
218
+ }
219
+
220
+ async function handleMarkEntry(entry, status) {
221
+ executingEntryId = entry.id;
222
+ try {
223
+ const updated = await recordEntryResult(entry.id, { status, notes: entryNote });
224
+ run = {
225
+ ...run,
226
+ entries: run.entries.map((e) => (e.id === entry.id ? { ...e, ...updated, status } : e))
227
+ };
228
+ entryNote = '';
229
+ } catch (e) {
230
+ showToast('error', e.message);
231
+ } finally {
232
+ executingEntryId = null;
233
+ }
234
+ }
235
+
236
+ async function handleStopExecution() {
237
+ try {
238
+ await updateRun(runId, { status: 'backlog' });
239
+ run = { ...run, status: 'backlog' };
240
+ mode = 'build';
241
+ runsVersion.update((v) => v + 1);
242
+ } catch (e) {
243
+ showToast('error', e.message);
244
+ }
245
+ }
246
+
247
+ async function handleCompleteRun() {
248
+ try {
249
+ await updateRun(runId, { status: 'complete' });
250
+ run = { ...run, status: 'complete' };
251
+ runsVersion.update((v) => v + 1);
252
+ showToast('success', 'Run marked as complete.');
253
+ } catch (e) {
254
+ showToast('error', e.message);
255
+ }
256
+ }
257
+
258
+ async function handleReopenRun() {
259
+ try {
260
+ await updateRun(runId, { status: 'backlog' });
261
+ run = { ...run, status: 'backlog' };
262
+ mode = 'build';
263
+ runsVersion.update((v) => v + 1);
264
+ showToast('success', 'Run reopened.');
265
+ } catch (e) {
266
+ showToast('error', e.message);
267
+ }
268
+ }
269
+
270
+ $: isLocked = run?.status === 'complete';
271
+
272
+ function onDragStart(e, entryId) {
273
+ dragEntryId = entryId;
274
+ e.dataTransfer.effectAllowed = 'move';
275
+ }
276
+
277
+ function onDragOver(e, entryId) {
278
+ e.preventDefault();
279
+ dragOverEntryId = entryId;
280
+ }
281
+
282
+ function onDrop(e, targetEntryId) {
283
+ e.preventDefault();
284
+ if (!dragEntryId || dragEntryId === targetEntryId) return;
285
+ const entries = [...run.entries];
286
+ const fromIdx = entries.findIndex((e) => e.id === dragEntryId);
287
+ const toIdx = entries.findIndex((e) => e.id === targetEntryId);
288
+ const [moved] = entries.splice(fromIdx, 1);
289
+ entries.splice(toIdx, 0, moved);
290
+ run = { ...run, entries };
291
+ dragEntryId = null;
292
+ dragOverEntryId = null;
293
+ }
294
+
295
+ function priorityClass(p) {
296
+ return p?.toLowerCase() ?? 'medium';
297
+ }
298
+
299
+ function statusClass(s) {
300
+ if (s === 'pass') return 'pass';
301
+ if (s === 'fail') return 'fail';
302
+ if (s === 'blocked') return 'warn';
303
+ if (s === 'skip') return 'muted';
304
+ return 'pending';
305
+ }
306
+
307
+ $: completedCount = run?.entries.filter((e) => e.status !== 'pending').length ?? 0;
308
+ $: totalCount = run?.entries.length ?? 0;
309
+ $: passCount = run?.entries.filter((e) => e.status === 'pass').length ?? 0;
310
+ $: failCount = run?.entries.filter((e) => e.status === 'fail').length ?? 0;
311
+ </script>
312
+
313
+ <svelte:head><title>{run?.title ?? 'Test Run'} — Plum</title></svelte:head>
314
+
315
+ <Toast {toast} />
316
+
317
+ <div class="breadcrumb">
318
+ <a href="/test-repository" class="bc-link">Test Repository</a>
319
+ <span class="bc-sep">›</span>
320
+ <span class="bc-current">{run?.title ?? '…'}</span>
321
+ </div>
322
+
323
+ {#if loading}
324
+ <div class="loading-state">Loading…</div>
325
+ {:else if run}
326
+ <div class="run-header">
327
+ <div class="run-header-left">
328
+ <h1 class="run-title">{run.title}</h1>
329
+ <span class="run-status-badge {run.status}">{run.status}</span>
330
+ </div>
331
+ <div class="run-header-actions">
332
+ {#if isLocked}
333
+ <Button variant="ghost" on:click={handleReopenRun}>Reopen</Button>
334
+ {:else if mode === 'build'}
335
+ <Button variant="ghost" on:click={handleSaveRun} disabled={saving}>
336
+ {saving ? 'Saving…' : 'Save'}
337
+ </Button>
338
+ {#if run.entries.length > 0}
339
+ <Button on:click={handleStartExecution}>Start Execution</Button>
340
+ {/if}
341
+ {:else if run.status === 'in-progress'}
342
+ <Button variant="ghost" on:click={handleStopExecution}>Stop Execution</Button>
343
+ <Button variant="ghost" on:click={() => (mode = 'build')}>← Edit Run</Button>
344
+ <Button on:click={handleCompleteRun}>Mark Complete</Button>
345
+ {:else}
346
+ <Button variant="ghost" on:click={() => (mode = 'build')}>← Edit Run</Button>
347
+ {/if}
348
+ </div>
349
+ </div>
350
+
351
+ <!-- Mode tabs -->
352
+ <div class="mode-tabs">
353
+ <button class="mode-tab" class:active={mode === 'build'} on:click={() => (mode = 'build')}>
354
+ Build
355
+ </button>
356
+ <button class="mode-tab" class:active={mode === 'execute'} on:click={() => (mode = 'execute')}>
357
+ Execute
358
+ {#if totalCount > 0}
359
+ <span class="exec-progress">{completedCount}/{totalCount}</span>
360
+ {/if}
361
+ </button>
362
+ </div>
363
+
364
+ {#if mode === 'build'}
365
+ <div
366
+ class="builder-workspace"
367
+ class:locked-workspace={isLocked}
368
+ transition:fly={{ y: 4, duration: 150 }}
369
+ >
370
+ <!-- Left: run entries -->
371
+ <div class="builder-panel left-panel">
372
+ <div class="builder-panel-head">
373
+ <h2 class="builder-panel-title">In this run</h2>
374
+ <span class="count-chip">{run.entries.length}</span>
375
+ {#if isLocked}
376
+ <span class="locked-badge">locked</span>
377
+ {/if}
378
+ </div>
379
+
380
+ {#if run.entries.length === 0}
381
+ <div class="drop-hint">No cases in this run.</div>
382
+ {:else}
383
+ <div class="entry-sort-bar">
384
+ {#each [['order', 'Order'], ['name', 'Name'], ['priority', 'Priority'], ['status', 'Status'], ['suite', 'Suite']] as [val, label]}
385
+ <button
386
+ class="entry-sort-chip"
387
+ class:active={entriesSort.by === val}
388
+ on:click={() => applyEntriesSort(val)}
389
+ >
390
+ {label}{#if entriesSort.by === val}<span class="entry-sort-dir"
391
+ >{entriesSort.order === 'asc' ? ' ↑' : ' ↓'}</span
392
+ >{/if}
393
+ </button>
394
+ {/each}
395
+ </div>
396
+ <div class="entry-list" role="list">
397
+ {#each sortedEntries as entry (entry.id)}
398
+ <div
399
+ class="entry-row"
400
+ class:entry-locked={isLocked}
401
+ role="listitem"
402
+ draggable={!isLocked && entriesSort.by === 'order'}
403
+ on:dragstart={isLocked || entriesSort.by !== 'order'
404
+ ? null
405
+ : (e) => onDragStart(e, entry.id)}
406
+ on:dragover={isLocked || entriesSort.by !== 'order'
407
+ ? null
408
+ : (e) => onDragOver(e, entry.id)}
409
+ on:dragleave={isLocked || entriesSort.by !== 'order'
410
+ ? null
411
+ : () => (dragOverEntryId = null)}
412
+ on:drop={isLocked || entriesSort.by !== 'order' ? null : (e) => onDrop(e, entry.id)}
413
+ class:dragging={dragEntryId === entry.id}
414
+ class:drag-over={dragOverEntryId === entry.id}
415
+ >
416
+ {#if !isLocked && entriesSort.by === 'order'}
417
+ <svg
418
+ class="drag-handle"
419
+ width="12"
420
+ height="12"
421
+ viewBox="0 0 24 24"
422
+ fill="currentColor"
423
+ >
424
+ <circle cx="9" cy="5" r="1.5" /><circle cx="15" cy="5" r="1.5" />
425
+ <circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" />
426
+ <circle cx="9" cy="19" r="1.5" /><circle cx="15" cy="19" r="1.5" />
427
+ </svg>
428
+ {/if}
429
+ <div class="entry-info">
430
+ <div class="entry-badges">
431
+ <span class="id-chip">{entry.case.displayId}</span>
432
+ <span class="priority-badge {priorityClass(entry.case.priority)}"
433
+ >{entry.case.priority}</span
434
+ >
435
+ </div>
436
+ <p class="entry-title">{entry.case.title}</p>
437
+ <span class="entry-suite">{entry.case.suite?.name ?? ''}</span>
438
+ </div>
439
+ {#if entry.case.isAutomated}
440
+ <span class="assignee-auto-label">Automated</span>
441
+ {:else}
442
+ <select
443
+ class="assignee-select"
444
+ value={entry.assignedToId ?? ''}
445
+ disabled={assigning === entry.id}
446
+ on:change={(e) => handleAssignEntry(entry.id, e.target.value)}
447
+ >
448
+ <option value="">Unassigned</option>
449
+ {#each members as m}
450
+ <option value={m.id}>{m.name}</option>
451
+ {/each}
452
+ </select>
453
+ {/if}
454
+ {#if !isLocked}
455
+ <button class="icon-btn danger" on:click={() => removeEntry(entry.id)}>
456
+ <svg width="11" height="11" viewBox="0 0 14 14" fill="none"
457
+ ><path
458
+ d="M1 1l12 12M13 1L1 13"
459
+ stroke="currentColor"
460
+ stroke-width="1.5"
461
+ stroke-linecap="round"
462
+ /></svg
463
+ >
464
+ </button>
465
+ {/if}
466
+ </div>
467
+ {/each}
468
+ </div>
469
+ {/if}
470
+ </div>
471
+
472
+ <!-- Right: suite browser (hidden when locked) -->
473
+ {#if !isLocked}
474
+ <div class="builder-panel right-panel">
475
+ <div class="builder-panel-head">
476
+ <h2 class="builder-panel-title">Suite browser</h2>
477
+ <input
478
+ type="text"
479
+ class="search-input"
480
+ bind:value={search}
481
+ placeholder="Search cases…"
482
+ />
483
+ </div>
484
+
485
+ <div class="suite-browser">
486
+ {#each filteredSuites as suite (suite.id)}
487
+ <div class="browser-suite">
488
+ <button
489
+ class="browser-suite-header"
490
+ on:click={() => {
491
+ if (expandedSuites.has(suite.id)) expandedSuites.delete(suite.id);
492
+ else expandedSuites.add(suite.id);
493
+ expandedSuites = new Set(expandedSuites);
494
+ }}
495
+ >
496
+ <svg
497
+ class="chevron"
498
+ class:open={expandedSuites.has(suite.id)}
499
+ width="12"
500
+ height="12"
501
+ viewBox="0 0 24 24"
502
+ fill="none"
503
+ stroke="currentColor"
504
+ stroke-width="2"
505
+ stroke-linecap="round"><polyline points="9 18 15 12 9 6" /></svg
506
+ >
507
+ <span class="browser-suite-name">{suite.name}</span>
508
+ <span class="browser-suite-count">{suite.cases?.length ?? 0}</span>
509
+ </button>
510
+ {#if expandedSuites.has(suite.id) && suite.cases?.length > 0}
511
+ <div class="browser-cases" transition:fly={{ y: -4, duration: 120 }}>
512
+ {#each suite.cases as tc (tc.id)}
513
+ <button class="browser-case" on:click={() => addCase(tc)}>
514
+ <span class="id-chip small">{tc.displayId}</span>
515
+ <span class="browser-case-title">{tc.title}</span>
516
+ <span class="priority-badge small {priorityClass(tc.priority)}"
517
+ >{tc.priority}</span
518
+ >
519
+ </button>
520
+ {/each}
521
+ </div>
522
+ {:else if expandedSuites.has(suite.id)}
523
+ <p class="browser-empty">
524
+ {search
525
+ ? 'No matching cases'
526
+ : suite._totalCases === 0
527
+ ? 'No cases in this suite'
528
+ : 'All cases added'}
529
+ </p>
530
+ {/if}
531
+ </div>
532
+ {/each}
533
+ {#if filteredSuites.length === 0}
534
+ <p class="browser-empty">No suites available.</p>
535
+ {/if}
536
+ </div>
537
+ </div>
538
+ {/if}
539
+ </div>
540
+ {:else}
541
+ <div class="execute-workspace" transition:fly={{ y: 4, duration: 150 }}>
542
+ {#if run.status !== 'in-progress' && !isLocked}
543
+ <div class="exec-locked-banner">
544
+ This run is not in progress — start execution to record results.
545
+ </div>
546
+ {/if}
547
+ <!-- Progress bar -->
548
+ {#if totalCount > 0}
549
+ <div class="progress-bar-wrap">
550
+ <div class="progress-stats">
551
+ <span class="stat pass">{passCount} passed</span>
552
+ <span class="stat fail">{failCount} failed</span>
553
+ <span class="stat muted">{totalCount - completedCount} remaining</span>
554
+ </div>
555
+ <div class="progress-bar">
556
+ <div class="progress-fill pass" style="width: {(passCount / totalCount) * 100}%"></div>
557
+ <div class="progress-fill fail" style="width: {(failCount / totalCount) * 100}%"></div>
558
+ </div>
559
+ </div>
560
+ {/if}
561
+
562
+ <div class="execute-list">
563
+ {#each run.entries as entry (entry.id)}
564
+ <div class="execute-row" class:expanded={executingEntryId === entry.id}>
565
+ <div class="execute-row-main">
566
+ <div class="exec-info">
567
+ {#if entry.case.steps?.length > 0}
568
+ <button
569
+ class="exec-chevron"
570
+ on:click={() => toggleExecSteps(entry.id)}
571
+ title="Toggle steps"
572
+ >
573
+ <svg
574
+ class="chevron"
575
+ class:open={expandedExecEntries.has(entry.id)}
576
+ width="12"
577
+ height="12"
578
+ viewBox="0 0 24 24"
579
+ fill="none"
580
+ stroke="currentColor"
581
+ stroke-width="2"
582
+ stroke-linecap="round"><polyline points="9 18 15 12 9 6" /></svg
583
+ >
584
+ </button>
585
+ {/if}
586
+ <span class="id-chip">{entry.case.displayId}</span>
587
+ {#if entry.case.isAutomated}<span class="auto-pill">Automated</span>{/if}
588
+ <div>
589
+ <p class="exec-title">{entry.case.title}</p>
590
+ <span class="exec-suite">{entry.case.suite?.name ?? ''}</span>
591
+ </div>
592
+ </div>
593
+ <div class="exec-actions">
594
+ {#if entry.case.isAutomated}
595
+ <div class="exec-assignee-row">
596
+ <span class="exec-assignee-name auto-label">Automated</span>
597
+ </div>
598
+ <div class="exec-assignee-divider"></div>
599
+ {:else if entry.assignedTo || (!isLocked && entry.assignedTo?.id !== currentUserId)}
600
+ <div class="exec-assignee-row">
601
+ {#if entry.assignedTo}
602
+ <span class="exec-assignee-name">{entry.assignedTo.name}</span>
603
+ {/if}
604
+ {#if !isLocked && entry.assignedTo?.id !== currentUserId}
605
+ <button
606
+ class="exec-assign-me"
607
+ on:click={() => handleAssignEntry(entry.id, currentUserId)}
608
+ disabled={assigning === entry.id}
609
+ >
610
+ Assign to me
611
+ </button>
612
+ {/if}
613
+ </div>
614
+ <div class="exec-assignee-divider"></div>
615
+ {/if}
616
+ {#if isLocked || run.status !== 'in-progress'}
617
+ <span class="result-chip {statusClass(entry.status)}">{entry.status}</span>
618
+ {:else if entry.status === 'pending' || entry.status === 'in-progress'}
619
+ <div class="exec-btns">
620
+ <button
621
+ class="exec-btn in-progress"
622
+ class:active={entry.status === 'in-progress'}
623
+ on:click={() => handleMarkEntry(entry, 'in-progress')}>In Progress</button
624
+ >
625
+ <button class="exec-btn pass" on:click={() => handleMarkEntry(entry, 'pass')}
626
+ >Pass</button
627
+ >
628
+ <button class="exec-btn fail" on:click={() => handleMarkEntry(entry, 'fail')}
629
+ >Fail</button
630
+ >
631
+ <button class="exec-btn warn" on:click={() => handleMarkEntry(entry, 'blocked')}
632
+ >Blocked</button
633
+ >
634
+ <button class="exec-btn muted" on:click={() => handleMarkEntry(entry, 'skip')}
635
+ >Skip</button
636
+ >
637
+ </div>
638
+ {:else}
639
+ <div class="exec-btns">
640
+ <span class="result-chip {statusClass(entry.status)}">{entry.status}</span>
641
+ <button
642
+ class="exec-reset"
643
+ on:click={() => handleMarkEntry(entry, 'pending')}
644
+ title="Reset to pending">↩ Reset</button
645
+ >
646
+ </div>
647
+ {/if}
648
+ </div>
649
+ </div>
650
+
651
+ <!-- Step viewer for current entry -->
652
+ {#if entry.case.steps && entry.case.steps.length > 0 && expandedExecEntries.has(entry.id)}
653
+ <div class="exec-steps">
654
+ {#each entry.case.steps as step, i}
655
+ <div class="exec-step">
656
+ <span class="exec-step-num">{i + 1}</span>
657
+ <div class="exec-step-body">
658
+ <p class="exec-step-action">{step.action}</p>
659
+ {#if step.testData}
660
+ <p class="exec-step-data">Data: {step.testData}</p>
661
+ {/if}
662
+ {#if step.expectedOutput}
663
+ <p class="exec-step-expected">Expected: {step.expectedOutput}</p>
664
+ {/if}
665
+ </div>
666
+ </div>
667
+ {/each}
668
+ </div>
669
+ {/if}
670
+ </div>
671
+ {/each}
672
+ </div>
673
+ </div>
674
+ {/if}
675
+ {/if}
676
+
677
+ <style>
678
+ .loading-state {
679
+ font-size: 0.875rem;
680
+ color: var(--text-muted);
681
+ padding: 3rem 0;
682
+ }
683
+
684
+ .breadcrumb {
685
+ display: flex;
686
+ align-items: center;
687
+ gap: 0.5rem;
688
+ font-size: 0.8125rem;
689
+ color: var(--text-muted);
690
+ margin-bottom: 1.5rem;
691
+ }
692
+
693
+ .bc-link {
694
+ color: var(--accent);
695
+ text-decoration: none;
696
+ }
697
+ .bc-link:hover {
698
+ text-decoration: underline;
699
+ }
700
+ .bc-sep {
701
+ color: var(--border);
702
+ }
703
+
704
+ /* ── Run header ── */
705
+ .run-header {
706
+ display: flex;
707
+ align-items: center;
708
+ justify-content: space-between;
709
+ gap: 1rem;
710
+ margin-bottom: 1.5rem;
711
+ padding-bottom: 1.25rem;
712
+ border-bottom: 1px solid var(--border);
713
+ }
714
+
715
+ .run-header-left {
716
+ display: flex;
717
+ align-items: center;
718
+ gap: 0.75rem;
719
+ }
720
+
721
+ .run-title {
722
+ font-size: 1.75rem;
723
+ font-weight: 600;
724
+ color: var(--text);
725
+ }
726
+
727
+ .run-status-badge {
728
+ font-size: 0.7rem;
729
+ font-weight: 500;
730
+ text-transform: capitalize;
731
+ border-radius: 100px;
732
+ padding: 0.2rem 0.6rem;
733
+ border: 1px solid var(--border);
734
+ color: var(--text-muted);
735
+ background: var(--bg-subtle);
736
+ }
737
+
738
+ .run-status-badge.complete {
739
+ background: var(--pass-soft);
740
+ color: var(--pass);
741
+ border-color: var(--pass);
742
+ }
743
+
744
+ .run-status-badge.in-progress {
745
+ background: var(--accent-soft);
746
+ color: var(--accent);
747
+ border-color: var(--accent);
748
+ }
749
+
750
+ .run-status-badge.backlog {
751
+ background: var(--bg-subtle);
752
+ color: var(--text-muted);
753
+ border-color: var(--border);
754
+ }
755
+
756
+ .locked-badge {
757
+ font-size: 0.62rem;
758
+ font-weight: 600;
759
+ letter-spacing: 0.07em;
760
+ text-transform: uppercase;
761
+ color: var(--text-muted);
762
+ background: var(--bg-subtle);
763
+ border: 1px solid var(--border);
764
+ border-radius: 100px;
765
+ padding: 0.1rem 0.45rem;
766
+ margin-left: auto;
767
+ }
768
+
769
+ .locked-workspace {
770
+ grid-template-columns: 1fr;
771
+ }
772
+
773
+ .entry-row.entry-locked {
774
+ cursor: default;
775
+ }
776
+ .entry-row.entry-locked:hover {
777
+ border-color: var(--border);
778
+ }
779
+ .run-status-badge.in-progress {
780
+ background: var(--warn-soft);
781
+ color: var(--warn);
782
+ border-color: var(--warn);
783
+ }
784
+
785
+ .run-header-actions {
786
+ display: flex;
787
+ align-items: center;
788
+ gap: 0.5rem;
789
+ }
790
+
791
+ /* ── Mode tabs ── */
792
+ .mode-tabs {
793
+ display: flex;
794
+ gap: 0.125rem;
795
+ border-bottom: 1px solid var(--border);
796
+ margin-bottom: 1.5rem;
797
+ }
798
+
799
+ .mode-tab {
800
+ display: flex;
801
+ align-items: center;
802
+ gap: 0.5rem;
803
+ padding: 0.5rem 1rem 0.625rem;
804
+ font-family: var(--font-body);
805
+ font-size: 0.875rem;
806
+ color: var(--text-muted);
807
+ background: none;
808
+ border: none;
809
+ border-bottom: 2px solid transparent;
810
+ cursor: pointer;
811
+ margin-bottom: -1px;
812
+ transition:
813
+ color var(--duration-fast),
814
+ border-color var(--duration-fast);
815
+ }
816
+
817
+ .mode-tab:hover {
818
+ color: var(--text);
819
+ }
820
+ .mode-tab.active {
821
+ color: var(--accent);
822
+ border-bottom-color: var(--accent);
823
+ font-weight: 500;
824
+ }
825
+
826
+ .exec-progress {
827
+ font-size: 0.7rem;
828
+ background: var(--bg-subtle);
829
+ border: 1px solid var(--border);
830
+ border-radius: 100px;
831
+ padding: 0.05rem 0.4rem;
832
+ color: var(--text-muted);
833
+ }
834
+
835
+ /* ── Builder ── */
836
+ .builder-workspace {
837
+ display: grid;
838
+ grid-template-columns: 1fr 1fr;
839
+ gap: 1.5rem;
840
+ align-items: start;
841
+ }
842
+
843
+ .builder-panel {
844
+ background: var(--bg-elevated);
845
+ border: 1px solid var(--border);
846
+ border-radius: var(--radius-md);
847
+ overflow: hidden;
848
+ }
849
+
850
+ .builder-panel-head {
851
+ display: flex;
852
+ align-items: center;
853
+ gap: 0.75rem;
854
+ padding: 0.875rem 1rem;
855
+ border-bottom: 1px solid var(--border);
856
+ background: var(--bg-subtle);
857
+ }
858
+
859
+ .builder-panel-title {
860
+ font-size: 0.875rem;
861
+ font-weight: 500;
862
+ color: var(--text);
863
+ }
864
+
865
+ .count-chip {
866
+ font-size: 0.7rem;
867
+ background: var(--bg-elevated);
868
+ border: 1px solid var(--border);
869
+ border-radius: 100px;
870
+ padding: 0.05rem 0.4rem;
871
+ color: var(--text-muted);
872
+ }
873
+
874
+ .search-input {
875
+ flex: 1;
876
+ height: 28px;
877
+ padding: 0 0.625rem;
878
+ font-family: var(--font-body);
879
+ font-size: 0.8125rem;
880
+ color: var(--text);
881
+ background: var(--bg-elevated);
882
+ border: 1px solid var(--border);
883
+ border-radius: var(--radius-sm);
884
+ outline: none;
885
+ }
886
+
887
+ .search-input:focus {
888
+ border-color: var(--accent);
889
+ }
890
+
891
+ .drop-hint {
892
+ padding: 2rem 1.25rem;
893
+ font-size: 0.8125rem;
894
+ color: var(--text-muted);
895
+ text-align: center;
896
+ line-height: 1.6;
897
+ }
898
+
899
+ .entry-sort-bar {
900
+ display: flex;
901
+ align-items: center;
902
+ gap: 0.25rem;
903
+ padding: 0.5rem 0.75rem;
904
+ border-bottom: 1px solid var(--border);
905
+ flex-wrap: wrap;
906
+ }
907
+
908
+ .entry-sort-chip {
909
+ font-size: 0.75rem;
910
+ padding: 0.2rem 0.55rem;
911
+ border-radius: 100px;
912
+ border: 1px solid var(--border);
913
+ background: var(--bg);
914
+ color: var(--text-muted);
915
+ cursor: pointer;
916
+ font-family: var(--font-body);
917
+ transition:
918
+ background var(--duration-fast),
919
+ border-color var(--duration-fast),
920
+ color var(--duration-fast);
921
+ }
922
+
923
+ .entry-sort-chip.active {
924
+ background: var(--accent-soft);
925
+ border-color: var(--accent);
926
+ color: var(--accent);
927
+ }
928
+
929
+ .entry-sort-dir {
930
+ opacity: 0.7;
931
+ }
932
+
933
+ .entry-list {
934
+ padding: 0.625rem;
935
+ display: flex;
936
+ flex-direction: column;
937
+ gap: 0.375rem;
938
+ min-height: 120px;
939
+ }
940
+
941
+ .entry-row {
942
+ display: flex;
943
+ align-items: flex-start;
944
+ gap: 0.5rem;
945
+ padding: 0.625rem 0.75rem;
946
+ background: var(--bg);
947
+ border: 1px solid var(--border);
948
+ border-radius: var(--radius-sm);
949
+ cursor: grab;
950
+ transition:
951
+ border-color var(--duration-fast),
952
+ opacity var(--duration-fast);
953
+ }
954
+
955
+ .entry-row:hover {
956
+ border-color: var(--accent);
957
+ }
958
+ .entry-row.dragging {
959
+ opacity: 0.4;
960
+ }
961
+ .entry-row.drag-over {
962
+ border-color: var(--accent);
963
+ background: var(--accent-soft);
964
+ }
965
+
966
+ .drag-handle {
967
+ color: var(--border);
968
+ flex-shrink: 0;
969
+ margin-top: 0.125rem;
970
+ }
971
+
972
+ .entry-info {
973
+ flex: 1;
974
+ min-width: 0;
975
+ }
976
+
977
+ .entry-badges {
978
+ display: flex;
979
+ align-items: center;
980
+ gap: 0.375rem;
981
+ margin-bottom: 0.25rem;
982
+ }
983
+
984
+ .entry-title {
985
+ font-size: 0.8125rem;
986
+ color: var(--text);
987
+ line-height: 1.4;
988
+ }
989
+
990
+ .entry-suite {
991
+ font-size: 0.7rem;
992
+ color: var(--text-muted);
993
+ }
994
+
995
+ .assignee-auto-label {
996
+ font-size: 0.72rem;
997
+ font-weight: 500;
998
+ color: var(--accent);
999
+ background: var(--accent-soft);
1000
+ border-radius: 100px;
1001
+ padding: 0.15rem 0.5rem;
1002
+ flex-shrink: 0;
1003
+ }
1004
+
1005
+ .auto-pill {
1006
+ font-size: 0.62rem;
1007
+ font-weight: 600;
1008
+ text-transform: uppercase;
1009
+ letter-spacing: 0.05em;
1010
+ color: var(--accent);
1011
+ background: var(--accent-soft);
1012
+ border-radius: 100px;
1013
+ padding: 0.1rem 0.4rem;
1014
+ flex-shrink: 0;
1015
+ }
1016
+
1017
+ .auto-label {
1018
+ color: var(--accent);
1019
+ font-weight: 500;
1020
+ }
1021
+
1022
+ .exec-locked-banner {
1023
+ font-size: 0.8125rem;
1024
+ color: var(--text-muted);
1025
+ background: var(--bg-subtle);
1026
+ border: 1px solid var(--border);
1027
+ border-radius: var(--radius-sm);
1028
+ padding: 0.625rem 1rem;
1029
+ margin-bottom: 1rem;
1030
+ }
1031
+
1032
+ .assignee-select {
1033
+ font-size: 0.75rem;
1034
+ font-family: var(--font-body);
1035
+ color: var(--text);
1036
+ background: var(--bg);
1037
+ border: 1px solid var(--border);
1038
+ border-radius: var(--radius-sm);
1039
+ padding: 0.2rem 0.4rem;
1040
+ cursor: pointer;
1041
+ flex-shrink: 0;
1042
+ }
1043
+
1044
+ .assignee-select:focus {
1045
+ outline: none;
1046
+ border-color: var(--accent);
1047
+ }
1048
+
1049
+ /* ── Suite browser ── */
1050
+ .right-panel {
1051
+ position: sticky;
1052
+ top: calc(56px + 1.5rem);
1053
+ height: calc(100vh - 56px - 3rem);
1054
+ display: flex;
1055
+ flex-direction: column;
1056
+ overflow: hidden;
1057
+ }
1058
+
1059
+ .suite-browser {
1060
+ flex: 1;
1061
+ min-height: 0;
1062
+ padding: 0.625rem;
1063
+ display: flex;
1064
+ flex-direction: column;
1065
+ gap: 0.25rem;
1066
+ overflow-y: auto;
1067
+ }
1068
+
1069
+ .browser-suite {
1070
+ border: 1px solid var(--border);
1071
+ border-radius: var(--radius-sm);
1072
+ overflow: hidden;
1073
+ flex-shrink: 0;
1074
+ }
1075
+
1076
+ .browser-suite-header {
1077
+ display: flex;
1078
+ align-items: center;
1079
+ gap: 0.5rem;
1080
+ width: 100%;
1081
+ min-height: 40px;
1082
+ padding: 0.5rem 0.75rem;
1083
+ background: var(--bg-subtle);
1084
+ border: none;
1085
+ cursor: pointer;
1086
+ text-align: left;
1087
+ font-family: var(--font-body);
1088
+ transition: background var(--duration-fast);
1089
+ }
1090
+
1091
+ .browser-suite-header:hover {
1092
+ background: var(--bg);
1093
+ }
1094
+
1095
+ .chevron {
1096
+ color: var(--text-muted);
1097
+ transition: transform var(--duration-fast);
1098
+ flex-shrink: 0;
1099
+ }
1100
+
1101
+ .chevron.open {
1102
+ transform: rotate(90deg);
1103
+ }
1104
+
1105
+ .browser-suite-name {
1106
+ font-size: 0.8125rem;
1107
+ font-weight: 500;
1108
+ color: var(--text);
1109
+ flex: 1;
1110
+ }
1111
+
1112
+ .browser-suite-count {
1113
+ font-size: 0.7rem;
1114
+ color: var(--text-muted);
1115
+ }
1116
+
1117
+ .browser-cases {
1118
+ display: flex;
1119
+ flex-direction: column;
1120
+ gap: 0;
1121
+ border-top: 1px solid var(--border);
1122
+ max-height: 380px;
1123
+ overflow-y: auto;
1124
+ }
1125
+
1126
+ .browser-case {
1127
+ display: flex;
1128
+ align-items: center;
1129
+ gap: 0.5rem;
1130
+ width: 100%;
1131
+ padding: 0.5rem 0.75rem;
1132
+ background: var(--bg-elevated);
1133
+ border: none;
1134
+ border-top: 1px solid var(--border);
1135
+ cursor: pointer;
1136
+ text-align: left;
1137
+ font-family: var(--font-body);
1138
+ transition: background var(--duration-fast);
1139
+ }
1140
+
1141
+ .browser-case:first-child {
1142
+ border-top: none;
1143
+ }
1144
+ .browser-case:hover {
1145
+ background: var(--accent-soft);
1146
+ }
1147
+
1148
+ .browser-case-title {
1149
+ font-size: 0.8125rem;
1150
+ color: var(--text);
1151
+ flex: 1;
1152
+ min-width: 0;
1153
+ overflow: hidden;
1154
+ text-overflow: ellipsis;
1155
+ white-space: nowrap;
1156
+ }
1157
+
1158
+ .browser-empty {
1159
+ padding: 0.625rem 0.75rem;
1160
+ font-size: 0.75rem;
1161
+ color: var(--text-muted);
1162
+ }
1163
+
1164
+ /* ── Execute ── */
1165
+ .execute-workspace {
1166
+ }
1167
+
1168
+ .progress-bar-wrap {
1169
+ margin-bottom: 1.25rem;
1170
+ }
1171
+
1172
+ .progress-stats {
1173
+ display: flex;
1174
+ align-items: center;
1175
+ gap: 1rem;
1176
+ margin-bottom: 0.5rem;
1177
+ }
1178
+
1179
+ .stat {
1180
+ font-size: 0.8125rem;
1181
+ font-weight: 500;
1182
+ }
1183
+
1184
+ .stat.pass {
1185
+ color: var(--pass);
1186
+ }
1187
+ .stat.fail {
1188
+ color: var(--fail);
1189
+ }
1190
+ .stat.muted {
1191
+ color: var(--text-muted);
1192
+ }
1193
+
1194
+ .progress-bar {
1195
+ height: 6px;
1196
+ background: var(--bg-subtle);
1197
+ border-radius: 100px;
1198
+ overflow: hidden;
1199
+ display: flex;
1200
+ }
1201
+
1202
+ .progress-fill {
1203
+ height: 100%;
1204
+ border-radius: 100px;
1205
+ transition: width 0.3s var(--ease-out);
1206
+ }
1207
+
1208
+ .progress-fill.pass {
1209
+ background: var(--pass);
1210
+ }
1211
+ .progress-fill.fail {
1212
+ background: var(--fail);
1213
+ }
1214
+
1215
+ .execute-list {
1216
+ display: flex;
1217
+ flex-direction: column;
1218
+ gap: 0.5rem;
1219
+ }
1220
+
1221
+ .execute-row {
1222
+ background: var(--bg-elevated);
1223
+ border: 1px solid var(--border);
1224
+ border-radius: var(--radius-md);
1225
+ overflow: hidden;
1226
+ transition: border-color var(--duration-fast);
1227
+ }
1228
+
1229
+ .execute-row:hover {
1230
+ border-color: color-mix(in srgb, var(--text-muted) 40%, var(--border));
1231
+ }
1232
+
1233
+ .execute-row-main {
1234
+ display: flex;
1235
+ align-items: center;
1236
+ gap: 1rem;
1237
+ padding: 0.875rem 1rem;
1238
+ }
1239
+
1240
+ .exec-info {
1241
+ display: flex;
1242
+ align-items: flex-start;
1243
+ gap: 0.625rem;
1244
+ flex: 1;
1245
+ min-width: 0;
1246
+ }
1247
+
1248
+ .exec-title {
1249
+ font-size: 0.875rem;
1250
+ font-weight: 500;
1251
+ color: var(--text);
1252
+ line-height: 1.4;
1253
+ }
1254
+
1255
+ .exec-suite {
1256
+ font-size: 0.75rem;
1257
+ color: var(--text-muted);
1258
+ }
1259
+
1260
+ .exec-actions {
1261
+ display: flex;
1262
+ align-items: center;
1263
+ gap: 0.375rem;
1264
+ flex-shrink: 0;
1265
+ }
1266
+
1267
+ .exec-btns {
1268
+ display: flex;
1269
+ align-items: center;
1270
+ gap: 0.25rem;
1271
+ flex-wrap: wrap;
1272
+ }
1273
+
1274
+ .exec-btn {
1275
+ height: 26px;
1276
+ padding: 0 0.6rem;
1277
+ border-radius: var(--radius-sm);
1278
+ border: 1px solid var(--border);
1279
+ background: var(--bg);
1280
+ cursor: pointer;
1281
+ font-size: 0.75rem;
1282
+ font-family: var(--font-body);
1283
+ font-weight: 500;
1284
+ color: var(--text-muted);
1285
+ display: flex;
1286
+ align-items: center;
1287
+ white-space: nowrap;
1288
+ transition:
1289
+ background var(--duration-fast),
1290
+ border-color var(--duration-fast),
1291
+ color var(--duration-fast);
1292
+ }
1293
+
1294
+ .exec-btn.in-progress:hover,
1295
+ .exec-btn.in-progress.active {
1296
+ background: var(--accent-soft);
1297
+ border-color: var(--accent);
1298
+ color: var(--accent);
1299
+ }
1300
+ .exec-btn.pass:hover {
1301
+ background: var(--pass-soft);
1302
+ border-color: var(--pass);
1303
+ color: var(--pass);
1304
+ }
1305
+ .exec-btn.fail:hover {
1306
+ background: var(--fail-soft);
1307
+ border-color: var(--fail);
1308
+ color: var(--fail);
1309
+ }
1310
+ .exec-btn.warn:hover {
1311
+ background: var(--warn-soft);
1312
+ border-color: var(--warn);
1313
+ color: var(--warn);
1314
+ }
1315
+ .exec-btn.muted:hover {
1316
+ background: var(--bg-subtle);
1317
+ border-color: var(--text-muted);
1318
+ color: var(--text-muted);
1319
+ }
1320
+
1321
+ .exec-reset {
1322
+ height: 26px;
1323
+ padding: 0 0.6rem;
1324
+ font-size: 0.75rem;
1325
+ font-family: var(--font-body);
1326
+ color: var(--text-muted);
1327
+ background: var(--bg);
1328
+ border: 1px solid var(--border);
1329
+ border-radius: var(--radius-sm);
1330
+ cursor: pointer;
1331
+ transition:
1332
+ color var(--duration-fast),
1333
+ border-color var(--duration-fast);
1334
+ }
1335
+
1336
+ .exec-reset:hover {
1337
+ color: var(--text);
1338
+ border-color: var(--text-muted);
1339
+ }
1340
+
1341
+ .exec-assignee-row {
1342
+ display: flex;
1343
+ align-items: center;
1344
+ gap: 0.5rem;
1345
+ }
1346
+
1347
+ .exec-assignee-divider {
1348
+ height: 1px;
1349
+ background: var(--border);
1350
+ margin: 0.375rem 0;
1351
+ }
1352
+
1353
+ .exec-assignee-name {
1354
+ font-size: 0.75rem;
1355
+ line-height: 26px;
1356
+ color: var(--text-muted);
1357
+ }
1358
+
1359
+ .exec-assign-me {
1360
+ font-size: 0.7rem;
1361
+ font-family: var(--font-body);
1362
+ color: var(--accent);
1363
+ background: none;
1364
+ border: 1px solid var(--accent);
1365
+ border-radius: 100px;
1366
+ padding: 0.1rem 0.5rem;
1367
+ cursor: pointer;
1368
+ transition: background var(--duration-fast);
1369
+ }
1370
+
1371
+ .exec-assign-me:hover {
1372
+ background: var(--accent-soft);
1373
+ }
1374
+
1375
+ .exec-assign-me:disabled {
1376
+ opacity: 0.5;
1377
+ cursor: not-allowed;
1378
+ }
1379
+
1380
+ .result-chip {
1381
+ font-size: 0.7rem;
1382
+ font-weight: 600;
1383
+ text-transform: uppercase;
1384
+ letter-spacing: 0.05em;
1385
+ padding: 0.2rem 0.6rem;
1386
+ border-radius: 100px;
1387
+ }
1388
+
1389
+ .result-chip.pass {
1390
+ background: var(--pass-soft);
1391
+ color: var(--pass);
1392
+ }
1393
+ .result-chip.fail {
1394
+ background: var(--fail-soft);
1395
+ color: var(--fail);
1396
+ }
1397
+ .result-chip.warn {
1398
+ background: var(--warn-soft);
1399
+ color: var(--warn);
1400
+ }
1401
+ .result-chip.muted {
1402
+ background: var(--bg-subtle);
1403
+ color: var(--text-muted);
1404
+ }
1405
+ .result-chip.pending {
1406
+ background: var(--bg-subtle);
1407
+ color: var(--text-muted);
1408
+ }
1409
+
1410
+ .exec-chevron {
1411
+ background: none;
1412
+ border: none;
1413
+ padding: 0.125rem;
1414
+ cursor: pointer;
1415
+ color: var(--text-muted);
1416
+ display: flex;
1417
+ align-items: center;
1418
+ flex-shrink: 0;
1419
+ transition: color var(--duration-fast);
1420
+ }
1421
+
1422
+ .exec-chevron:hover {
1423
+ color: var(--text);
1424
+ }
1425
+
1426
+ /* ── Steps in execute view ── */
1427
+ .exec-steps {
1428
+ border-top: 1px solid var(--border);
1429
+ padding: 0.75rem 1rem;
1430
+ display: flex;
1431
+ flex-direction: column;
1432
+ gap: 0.5rem;
1433
+ background: var(--bg-subtle);
1434
+ }
1435
+
1436
+ .exec-step {
1437
+ display: flex;
1438
+ gap: 0.5rem;
1439
+ align-items: flex-start;
1440
+ }
1441
+
1442
+ .exec-step-num {
1443
+ font-size: 0.7rem;
1444
+ font-weight: 600;
1445
+ color: var(--text-muted);
1446
+ width: 20px;
1447
+ flex-shrink: 0;
1448
+ padding-top: 0.1rem;
1449
+ }
1450
+
1451
+ .exec-step-body {
1452
+ flex: 1;
1453
+ }
1454
+
1455
+ .exec-step-action {
1456
+ font-size: 0.8125rem;
1457
+ color: var(--text);
1458
+ line-height: 1.4;
1459
+ }
1460
+
1461
+ .exec-step-data,
1462
+ .exec-step-expected {
1463
+ font-size: 0.75rem;
1464
+ color: var(--text-muted);
1465
+ font-family: 'JetBrains Mono', monospace;
1466
+ margin-top: 0.2rem;
1467
+ }
1468
+
1469
+ /* ── Shared chips ── */
1470
+ .id-chip {
1471
+ font-family: 'JetBrains Mono', monospace;
1472
+ font-weight: 600;
1473
+ color: var(--accent);
1474
+ background: var(--accent-soft);
1475
+ border-radius: 4px;
1476
+ letter-spacing: 0.02em;
1477
+ font-size: 0.7rem;
1478
+ padding: 0.1rem 0.4rem;
1479
+ flex-shrink: 0;
1480
+ }
1481
+
1482
+ .id-chip.small {
1483
+ font-size: 0.63rem;
1484
+ }
1485
+
1486
+ .priority-badge {
1487
+ font-size: 0.65rem;
1488
+ font-weight: 500;
1489
+ border-radius: 100px;
1490
+ padding: 0.12rem 0.45rem;
1491
+ flex-shrink: 0;
1492
+ }
1493
+
1494
+ .priority-badge.small {
1495
+ font-size: 0.6rem;
1496
+ }
1497
+ .priority-badge.critical {
1498
+ background: var(--fail-soft);
1499
+ color: var(--fail);
1500
+ }
1501
+ .priority-badge.high {
1502
+ background: var(--warn-soft);
1503
+ color: var(--warn);
1504
+ }
1505
+ .priority-badge.medium {
1506
+ background: var(--node-soft);
1507
+ color: var(--node);
1508
+ }
1509
+ .priority-badge.low {
1510
+ background: var(--bg-subtle);
1511
+ color: var(--text-muted);
1512
+ }
1513
+
1514
+ .icon-btn {
1515
+ display: flex;
1516
+ align-items: center;
1517
+ justify-content: center;
1518
+ width: 26px;
1519
+ height: 26px;
1520
+ border: none;
1521
+ border-radius: var(--radius-sm);
1522
+ background: transparent;
1523
+ cursor: pointer;
1524
+ color: var(--text-muted);
1525
+ transition:
1526
+ background var(--duration-fast),
1527
+ color var(--duration-fast);
1528
+ flex-shrink: 0;
1529
+ }
1530
+
1531
+ .icon-btn:hover {
1532
+ background: var(--bg-subtle);
1533
+ color: var(--text);
1534
+ }
1535
+ .icon-btn.danger:hover {
1536
+ background: var(--fail-soft);
1537
+ color: var(--fail);
1538
+ }
1539
+
1540
+ @media (max-width: 768px) {
1541
+ .builder-workspace {
1542
+ grid-template-columns: 1fr;
1543
+ }
1544
+
1545
+ .right-panel {
1546
+ display: none;
1547
+ }
1548
+ }
1549
+ </style>