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,1490 @@
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 } from 'svelte';
20
+ import { page } from '$app/stores';
21
+ import { fly, fade } from 'svelte/transition';
22
+ import {
23
+ fetchSuite,
24
+ updateSuite,
25
+ createTestCase,
26
+ fetchTestCase,
27
+ updateTestCase,
28
+ saveSteps,
29
+ deleteTestCase
30
+ } from '$lib/api/repository';
31
+ import EmptyState from '$lib/components/ui/EmptyState.svelte';
32
+ import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
33
+ import Modal from '$lib/components/ui/Modal.svelte';
34
+ import Toast from '$lib/components/ui/Toast.svelte';
35
+ import Button from '$lib/components/ui/Button.svelte';
36
+ import Pagination from '$lib/components/ui/Pagination.svelte';
37
+ import { TOAST_TIMEOUT_MS, SUITE_CASES_PER_PAGE } from '$lib/constants';
38
+
39
+ const suiteId = $page.params.id;
40
+
41
+ const PRIORITIES = ['Critical', 'High', 'Medium', 'Low'];
42
+
43
+ let suite = null;
44
+ let loading = true;
45
+ let toast = null;
46
+
47
+ let caseModalOpen = false;
48
+ let caseForm = { title: '', description: '', priority: 'Medium' };
49
+ let caseFormSaving = false;
50
+ let caseFormError = '';
51
+
52
+ let selectedCase = null;
53
+ let selectedCaseLoading = false;
54
+ let editingCase = false;
55
+ let editCaseForm = {};
56
+ let editCaseSaving = false;
57
+
58
+ let editingSteps = false;
59
+ let stepsForm = [];
60
+ let stepsSaving = false;
61
+
62
+ let historyTab = false;
63
+
64
+ let confirmDeleteCase = null;
65
+ let confirmDeleteCaseOpen = false;
66
+
67
+ let caseSearch = '';
68
+ let casesPage = 1;
69
+ let casesSort = (() => {
70
+ try {
71
+ const v = sessionStorage.getItem('plum:repo:cases:sort');
72
+ if (v) return JSON.parse(v);
73
+ } catch {}
74
+ return { by: 'createdAt', order: 'asc' };
75
+ })();
76
+
77
+ const PRIORITY_ORDER = { Critical: 0, High: 1, Medium: 2, Low: 3 };
78
+
79
+ $: caseQ = caseSearch.trim().toLowerCase();
80
+ $: filteredCases = suite?.cases
81
+ ? suite.cases.filter(
82
+ (c) =>
83
+ !caseQ ||
84
+ c.displayId.toLowerCase().includes(caseQ) ||
85
+ c.title.toLowerCase().includes(caseQ)
86
+ )
87
+ : [];
88
+ $: sortedCases = [...filteredCases].sort((a, b) => {
89
+ const dir = casesSort.order === 'asc' ? 1 : -1;
90
+ if (casesSort.by === 'displayId') return dir * a.displayId.localeCompare(b.displayId);
91
+ if (casesSort.by === 'name') return dir * a.title.localeCompare(b.title);
92
+ if (casesSort.by === 'priority')
93
+ return dir * ((PRIORITY_ORDER[a.priority] ?? 4) - (PRIORITY_ORDER[b.priority] ?? 4));
94
+ return dir * (new Date(a.createdAt) - new Date(b.createdAt));
95
+ });
96
+ $: casesTotalPages = Math.ceil(sortedCases.length / SUITE_CASES_PER_PAGE);
97
+ $: pagedCases = sortedCases.slice(
98
+ (casesPage - 1) * SUITE_CASES_PER_PAGE,
99
+ casesPage * SUITE_CASES_PER_PAGE
100
+ );
101
+ $: if (caseSearch) casesPage = 1;
102
+
103
+ function applyCasesSort(by) {
104
+ if (casesSort.by === by) {
105
+ casesSort.order = casesSort.order === 'asc' ? 'desc' : 'asc';
106
+ } else {
107
+ casesSort = { by, order: 'asc' };
108
+ }
109
+ try {
110
+ sessionStorage.setItem('plum:repo:cases:sort', JSON.stringify(casesSort));
111
+ } catch {}
112
+ }
113
+ let editSuiteOpen = false;
114
+ let editSuiteForm = {};
115
+ let editSuiteSaving = false;
116
+
117
+ function showToast(type, message) {
118
+ toast = { type, message };
119
+ setTimeout(() => (toast = null), TOAST_TIMEOUT_MS);
120
+ }
121
+
122
+ onMount(async () => {
123
+ try {
124
+ suite = await fetchSuite(suiteId);
125
+ } catch {
126
+ showToast('error', 'Failed to load suite');
127
+ } finally {
128
+ loading = false;
129
+ }
130
+ });
131
+
132
+ async function handleCreateCase() {
133
+ if (!caseForm.title.trim()) {
134
+ caseFormError = 'Title is required.';
135
+ return;
136
+ }
137
+ caseFormError = '';
138
+ caseFormSaving = true;
139
+ try {
140
+ const tc = await createTestCase({ suiteId, ...caseForm });
141
+ suite = { ...suite, cases: [...suite.cases, tc], _count: { cases: suite._count.cases + 1 } };
142
+ casesPage = Math.ceil(suite.cases.length / SUITE_CASES_PER_PAGE);
143
+ caseModalOpen = false;
144
+ caseForm = { title: '', description: '', priority: 'Medium' };
145
+ showToast('success', `${tc.displayId} created.`);
146
+ } catch (e) {
147
+ caseFormError = e.message;
148
+ } finally {
149
+ caseFormSaving = false;
150
+ }
151
+ }
152
+
153
+ async function selectCase(tc) {
154
+ selectedCase = null;
155
+ selectedCaseLoading = true;
156
+ historyTab = false;
157
+ editingCase = false;
158
+ editingSteps = false;
159
+ try {
160
+ selectedCase = await fetchTestCase(tc.id);
161
+ stepsForm = (selectedCase.steps ?? []).map((s) => ({ ...s }));
162
+ } catch {
163
+ showToast('error', 'Failed to load case');
164
+ } finally {
165
+ selectedCaseLoading = false;
166
+ }
167
+ }
168
+
169
+ function startEditCase() {
170
+ editCaseForm = {
171
+ title: selectedCase.title,
172
+ description: selectedCase.description,
173
+ priority: selectedCase.priority
174
+ };
175
+ editingCase = true;
176
+ }
177
+
178
+ async function handleUpdateCase() {
179
+ editCaseSaving = true;
180
+ try {
181
+ const updated = await updateTestCase(selectedCase.id, editCaseForm);
182
+ selectedCase = { ...selectedCase, ...updated };
183
+ suite = {
184
+ ...suite,
185
+ cases: suite.cases.map((c) => (c.id === selectedCase.id ? { ...c, ...updated } : c))
186
+ };
187
+ editingCase = false;
188
+ showToast('success', 'Test case updated.');
189
+ } catch (e) {
190
+ showToast('error', e.message);
191
+ } finally {
192
+ editCaseSaving = false;
193
+ }
194
+ }
195
+
196
+ function addStep() {
197
+ stepsForm = [...stepsForm, { action: '', testData: '', expectedOutput: '' }];
198
+ }
199
+
200
+ function removeStep(i) {
201
+ stepsForm = stepsForm.filter((_, idx) => idx !== i);
202
+ }
203
+
204
+ async function handleSaveSteps() {
205
+ stepsSaving = true;
206
+ try {
207
+ const saved = await saveSteps(selectedCase.id, stepsForm);
208
+ selectedCase = { ...selectedCase, steps: saved };
209
+ stepsForm = saved.map((s) => ({ ...s }));
210
+ editingSteps = false;
211
+ showToast('success', 'Steps saved.');
212
+ } catch (e) {
213
+ showToast('error', e.message);
214
+ } finally {
215
+ stepsSaving = false;
216
+ }
217
+ }
218
+
219
+ async function handleDeleteCase(id, displayId) {
220
+ try {
221
+ await deleteTestCase(id);
222
+ suite = {
223
+ ...suite,
224
+ cases: suite.cases.filter((c) => c.id !== id),
225
+ _count: { cases: suite._count.cases - 1 }
226
+ };
227
+ if (selectedCase?.id === id) selectedCase = null;
228
+ showToast('success', `${displayId} deleted.`);
229
+ } catch {
230
+ showToast('error', 'Failed to delete case.');
231
+ }
232
+ confirmDeleteCase = null;
233
+ confirmDeleteCaseOpen = false;
234
+ }
235
+
236
+ async function handleUpdateSuite() {
237
+ editSuiteSaving = true;
238
+ try {
239
+ const updated = await updateSuite(suiteId, editSuiteForm);
240
+ suite = { ...suite, ...updated };
241
+ editSuiteOpen = false;
242
+ showToast('success', 'Suite updated.');
243
+ } catch (e) {
244
+ showToast('error', e.message);
245
+ } finally {
246
+ editSuiteSaving = false;
247
+ }
248
+ }
249
+
250
+ function priorityClass(p) {
251
+ return p?.toLowerCase() ?? 'medium';
252
+ }
253
+
254
+ function resultClass(r) {
255
+ if (r === 'pass') return 'pass';
256
+ if (r === 'fail') return 'fail';
257
+ if (r === 'blocked') return 'warn';
258
+ return 'muted';
259
+ }
260
+ </script>
261
+
262
+ <svelte:head
263
+ ><title>{suite ? `${suite.displayId} — ${suite.name}` : 'Suite'} — Plum</title></svelte:head
264
+ >
265
+
266
+ <Toast {toast} />
267
+
268
+ <ConfirmModal
269
+ bind:open={confirmDeleteCaseOpen}
270
+ title="Delete Test Case"
271
+ confirmLabel="Delete"
272
+ on:confirm={() => handleDeleteCase(confirmDeleteCase?.id, confirmDeleteCase?.displayId)}
273
+ >
274
+ {#if confirmDeleteCase}
275
+ Delete <strong>{confirmDeleteCase.displayId} — {confirmDeleteCase.title}</strong>? This cannot
276
+ be undone.
277
+ {/if}
278
+ </ConfirmModal>
279
+
280
+ <Modal bind:open={caseModalOpen} title="New Test Case">
281
+ <div class="form-fields">
282
+ <div class="field">
283
+ <label class="field-label" for="tc-title">Title</label>
284
+ <input
285
+ id="tc-title"
286
+ type="text"
287
+ class="field-input"
288
+ bind:value={caseForm.title}
289
+ placeholder="User can log in with valid credentials"
290
+ />
291
+ </div>
292
+ <div class="field">
293
+ <label class="field-label" for="tc-desc">Description</label>
294
+ <textarea
295
+ id="tc-desc"
296
+ class="field-input field-textarea"
297
+ bind:value={caseForm.description}
298
+ placeholder="What this case verifies…"
299
+ rows="2"
300
+ ></textarea>
301
+ </div>
302
+ <div class="field">
303
+ <label class="field-label" for="tc-prio">Priority</label>
304
+ <select id="tc-prio" class="field-input" bind:value={caseForm.priority}>
305
+ {#each PRIORITIES as p}<option value={p}>{p}</option>{/each}
306
+ </select>
307
+ </div>
308
+ {#if caseFormError}<p class="form-error">{caseFormError}</p>{/if}
309
+ <div class="modal-actions">
310
+ <Button on:click={handleCreateCase} disabled={caseFormSaving}>
311
+ {caseFormSaving ? 'Creating…' : 'Create Case'}
312
+ </Button>
313
+ <Button
314
+ variant="ghost"
315
+ on:click={() => {
316
+ caseModalOpen = false;
317
+ caseFormError = '';
318
+ }}>Cancel</Button
319
+ >
320
+ </div>
321
+ </div>
322
+ </Modal>
323
+
324
+ <Modal bind:open={editSuiteOpen} title="Edit Suite">
325
+ <div class="form-fields">
326
+ <div class="field">
327
+ <label class="field-label" for="es-name">Name</label>
328
+ <input id="es-name" type="text" class="field-input" bind:value={editSuiteForm.name} />
329
+ </div>
330
+ <div class="field">
331
+ <label class="field-label" for="es-desc">Description</label>
332
+ <textarea
333
+ id="es-desc"
334
+ class="field-input field-textarea"
335
+ bind:value={editSuiteForm.description}
336
+ rows="2"
337
+ ></textarea>
338
+ </div>
339
+ <div class="field">
340
+ <label class="field-label" for="es-prio">Priority</label>
341
+ <select id="es-prio" class="field-input" bind:value={editSuiteForm.priority}>
342
+ {#each PRIORITIES as p}<option value={p}>{p}</option>{/each}
343
+ </select>
344
+ </div>
345
+ <div class="modal-actions">
346
+ <Button on:click={handleUpdateSuite} disabled={editSuiteSaving}>
347
+ {editSuiteSaving ? 'Saving…' : 'Save'}
348
+ </Button>
349
+ <Button variant="ghost" on:click={() => (editSuiteOpen = false)}>Cancel</Button>
350
+ </div>
351
+ </div>
352
+ </Modal>
353
+
354
+ {#if loading}
355
+ <div class="loading-state">Loading…</div>
356
+ {:else if suite}
357
+ <div class="breadcrumb">
358
+ <a href="/test-repository" class="bc-link">Test Repository</a>
359
+ <span class="bc-sep">›</span>
360
+ <span class="bc-current">{suite.displayId} — {suite.name}</span>
361
+ </div>
362
+
363
+ <div class="suite-header">
364
+ <div class="suite-title-row">
365
+ <span class="id-chip large">{suite.displayId}</span>
366
+ <span class="priority-badge {priorityClass(suite.priority)}">{suite.priority}</span>
367
+ <h1 class="suite-title">{suite.name}</h1>
368
+ <button
369
+ class="icon-btn"
370
+ title="Edit suite"
371
+ on:click={() => {
372
+ editSuiteForm = {
373
+ name: suite.name,
374
+ description: suite.description,
375
+ priority: suite.priority
376
+ };
377
+ editSuiteOpen = true;
378
+ }}
379
+ >
380
+ <svg
381
+ width="14"
382
+ height="14"
383
+ viewBox="0 0 24 24"
384
+ fill="none"
385
+ stroke="currentColor"
386
+ stroke-width="2"
387
+ stroke-linecap="round"
388
+ stroke-linejoin="round"
389
+ ><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path
390
+ d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
391
+ /></svg
392
+ >
393
+ </button>
394
+ </div>
395
+ {#if suite.description}
396
+ <p class="suite-desc">{suite.description}</p>
397
+ {/if}
398
+ <div class="suite-meta">
399
+ <span>{suite._count.cases} case{suite._count.cases !== 1 ? 's' : ''}</span>
400
+ <span class="meta-sep">·</span>
401
+ <span>Created by {suite.createdBy.name}</span>
402
+ </div>
403
+ </div>
404
+
405
+ <div class="workspace" class:split={selectedCase || selectedCaseLoading}>
406
+ <!-- Cases list -->
407
+ <div class="cases-panel">
408
+ <div class="panel-toolbar">
409
+ <h2 class="panel-title">Test Cases</h2>
410
+ <Button size="sm" on:click={() => (caseModalOpen = true)}>+ Add Case</Button>
411
+ </div>
412
+
413
+ {#if suite.cases.length > 0}
414
+ <div class="case-search">
415
+ <svg
416
+ class="case-search-icon"
417
+ width="12"
418
+ height="12"
419
+ viewBox="0 0 24 24"
420
+ fill="none"
421
+ stroke="currentColor"
422
+ stroke-width="2"
423
+ stroke-linecap="round"
424
+ stroke-linejoin="round"
425
+ >
426
+ <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
427
+ </svg>
428
+ <input
429
+ type="search"
430
+ class="case-search-input"
431
+ placeholder="Filter by ID or name…"
432
+ bind:value={caseSearch}
433
+ />
434
+ {#if caseSearch}
435
+ <button class="case-search-clear" on:click={() => (caseSearch = '')} aria-label="Clear">
436
+ <svg
437
+ width="10"
438
+ height="10"
439
+ viewBox="0 0 14 14"
440
+ fill="none"
441
+ stroke="currentColor"
442
+ stroke-width="1.5"
443
+ stroke-linecap="round"><path d="M1 1l12 12M13 1L1 13" /></svg
444
+ >
445
+ </button>
446
+ {/if}
447
+ </div>
448
+ {/if}
449
+
450
+ {#if suite.cases.length > 0}
451
+ <div class="case-sort-bar">
452
+ {#each [['createdAt', 'Date'], ['displayId', 'ID'], ['name', 'Name'], ['priority', 'Priority']] as [val, label]}
453
+ <button
454
+ class="case-sort-chip"
455
+ class:active={casesSort.by === val}
456
+ on:click={() => applyCasesSort(val)}
457
+ >
458
+ {label}
459
+ {#if casesSort.by === val}
460
+ <span class="case-sort-dir">{casesSort.order === 'asc' ? '↑' : '↓'}</span>
461
+ {/if}
462
+ </button>
463
+ {/each}
464
+ </div>
465
+ {/if}
466
+
467
+ {#if suite.cases.length === 0}
468
+ <EmptyState title="No cases yet" description="Add your first test case to this suite." />
469
+ {:else if filteredCases.length === 0}
470
+ <div class="case-no-results">No cases match "<strong>{caseSearch}</strong>"</div>
471
+ {:else}
472
+ <div class="case-list">
473
+ {#each pagedCases as tc (tc.id)}
474
+ <div
475
+ class="case-row"
476
+ class:selected={selectedCase?.id === tc.id}
477
+ role="button"
478
+ tabindex="0"
479
+ on:click={() => selectCase(tc)}
480
+ on:keydown={(e) => e.key === 'Enter' && selectCase(tc)}
481
+ >
482
+ <div class="case-row-top">
483
+ <span class="id-chip small">{tc.displayId}</span>
484
+ <span class="priority-badge small {priorityClass(tc.priority)}">{tc.priority}</span>
485
+ {#if tc.isAutomated}
486
+ <span class="auto-badge">Automated</span>
487
+ {/if}
488
+ <div class="case-row-actions" on:click|stopPropagation>
489
+ <button
490
+ class="icon-btn danger small"
491
+ on:click={() => {
492
+ confirmDeleteCase = { id: tc.id, displayId: tc.displayId, title: tc.title };
493
+ confirmDeleteCaseOpen = true;
494
+ }}
495
+ >
496
+ <svg
497
+ width="11"
498
+ height="11"
499
+ viewBox="0 0 24 24"
500
+ fill="none"
501
+ stroke="currentColor"
502
+ stroke-width="2"
503
+ stroke-linecap="round"
504
+ stroke-linejoin="round"
505
+ >
506
+ <polyline points="3 6 5 6 21 6" /><path
507
+ d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"
508
+ /><path d="M10 11v6M14 11v6" /><path d="M9 6V4h6v2" />
509
+ </svg>
510
+ </button>
511
+ </div>
512
+ </div>
513
+ <p class="case-title">{tc.title}</p>
514
+ <span class="case-steps"
515
+ >{tc._count.steps} step{tc._count.steps !== 1 ? 's' : ''}</span
516
+ >
517
+ </div>
518
+ {/each}
519
+ </div>
520
+ {#if casesTotalPages > 1}
521
+ <div class="cases-pagination">
522
+ <Pagination
523
+ current={casesPage}
524
+ total={casesTotalPages}
525
+ on:change={(e) => (casesPage = e.detail)}
526
+ />
527
+ </div>
528
+ {/if}
529
+ {/if}
530
+ </div>
531
+
532
+ <!-- Case detail panel -->
533
+ {#if selectedCase || selectedCaseLoading}
534
+ <div class="detail-panel" transition:fly={{ x: 20, duration: 200 }}>
535
+ {#if selectedCaseLoading}
536
+ <div class="detail-loading">Loading…</div>
537
+ {:else if selectedCase}
538
+ <div class="detail-header">
539
+ <div class="detail-title-row">
540
+ <span class="id-chip large">{selectedCase.displayId}</span>
541
+ <span class="priority-badge {priorityClass(selectedCase.priority)}"
542
+ >{selectedCase.priority}</span
543
+ >
544
+ {#if selectedCase.isAutomated}
545
+ <span class="auto-badge">Automated</span>
546
+ {/if}
547
+ <div class="detail-header-actions">
548
+ {#if !editingCase}
549
+ <button class="icon-btn" title="Edit case" on:click={startEditCase}>
550
+ <svg
551
+ width="13"
552
+ height="13"
553
+ viewBox="0 0 24 24"
554
+ fill="none"
555
+ stroke="currentColor"
556
+ stroke-width="2"
557
+ stroke-linecap="round"
558
+ stroke-linejoin="round"
559
+ ><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path
560
+ d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
561
+ /></svg
562
+ >
563
+ </button>
564
+ {/if}
565
+ <button
566
+ class="icon-btn"
567
+ title="Close"
568
+ on:click={() => {
569
+ selectedCase = null;
570
+ editingCase = false;
571
+ }}
572
+ >
573
+ <svg width="13" height="13" viewBox="0 0 14 14" fill="none"
574
+ ><path
575
+ d="M1 1l12 12M13 1L1 13"
576
+ stroke="currentColor"
577
+ stroke-width="1.5"
578
+ stroke-linecap="round"
579
+ /></svg
580
+ >
581
+ </button>
582
+ </div>
583
+ </div>
584
+
585
+ {#if editingCase}
586
+ <div class="edit-case-form" transition:fly={{ y: -4, duration: 150 }}>
587
+ <div class="field">
588
+ <label class="field-label" for="edit-title">Title</label>
589
+ <input
590
+ id="edit-title"
591
+ type="text"
592
+ class="field-input"
593
+ bind:value={editCaseForm.title}
594
+ />
595
+ </div>
596
+ <div class="field">
597
+ <label class="field-label" for="edit-desc">Description</label>
598
+ <textarea
599
+ id="edit-desc"
600
+ class="field-input field-textarea"
601
+ bind:value={editCaseForm.description}
602
+ rows="2"
603
+ ></textarea>
604
+ </div>
605
+ <div class="field">
606
+ <label class="field-label" for="edit-prio">Priority</label>
607
+ <select id="edit-prio" class="field-input" bind:value={editCaseForm.priority}>
608
+ {#each PRIORITIES as p}<option value={p}>{p}</option>{/each}
609
+ </select>
610
+ </div>
611
+ <div class="modal-actions">
612
+ <Button size="sm" on:click={handleUpdateCase} disabled={editCaseSaving}>
613
+ {editCaseSaving ? 'Saving…' : 'Save'}
614
+ </Button>
615
+ <Button size="sm" variant="ghost" on:click={() => (editingCase = false)}
616
+ >Cancel</Button
617
+ >
618
+ </div>
619
+ </div>
620
+ {:else}
621
+ <h2 class="detail-case-title">{selectedCase.title}</h2>
622
+ {#if selectedCase.description}
623
+ <p class="detail-case-desc">{selectedCase.description}</p>
624
+ {/if}
625
+ <p class="detail-meta">Created by {selectedCase.createdBy.name}</p>
626
+ {/if}
627
+ </div>
628
+
629
+ <!-- Sub-tabs: Steps / History -->
630
+ <div class="detail-tabs">
631
+ <button
632
+ class="detail-tab"
633
+ class:active={!historyTab}
634
+ on:click={() => (historyTab = false)}>Steps</button
635
+ >
636
+ <button
637
+ class="detail-tab"
638
+ class:active={historyTab}
639
+ on:click={() => (historyTab = true)}>History</button
640
+ >
641
+ </div>
642
+
643
+ {#if !historyTab}
644
+ <div class="steps-section" transition:fly={{ y: 4, duration: 140 }}>
645
+ <div class="steps-toolbar">
646
+ {#if !editingSteps}
647
+ <Button size="sm" variant="ghost" on:click={() => (editingSteps = true)}
648
+ >Edit Steps</Button
649
+ >
650
+ {:else}
651
+ <Button size="sm" on:click={handleSaveSteps} disabled={stepsSaving}>
652
+ {stepsSaving ? 'Saving…' : 'Save Steps'}
653
+ </Button>
654
+ <Button
655
+ size="sm"
656
+ variant="ghost"
657
+ on:click={() => {
658
+ editingSteps = false;
659
+ stepsForm = (selectedCase.steps ?? []).map((s) => ({ ...s }));
660
+ }}>Cancel</Button
661
+ >
662
+ {/if}
663
+ {#if editingSteps}
664
+ <button class="add-step-btn" on:click={addStep}>+ Add step</button>
665
+ {/if}
666
+ </div>
667
+
668
+ {#if editingSteps}
669
+ <div class="steps-editor">
670
+ {#if stepsForm.length === 0}
671
+ <p class="no-steps">No steps. Click "+ Add step" to begin.</p>
672
+ {:else}
673
+ {#each stepsForm as step, i (i)}
674
+ <div class="step-editor-row">
675
+ <span class="step-num">{i + 1}</span>
676
+ <div class="step-editor-fields">
677
+ <div class="step-field">
678
+ <label class="step-field-label">Step</label>
679
+ <input
680
+ type="text"
681
+ class="field-input"
682
+ bind:value={step.action}
683
+ placeholder="Describe the action…"
684
+ />
685
+ </div>
686
+ <div class="step-field">
687
+ <label class="step-field-label">Test Data</label>
688
+ <input
689
+ type="text"
690
+ class="field-input"
691
+ bind:value={step.testData}
692
+ placeholder="Optional"
693
+ />
694
+ </div>
695
+ <div class="step-field">
696
+ <label class="step-field-label">Expected Output</label>
697
+ <input
698
+ type="text"
699
+ class="field-input"
700
+ bind:value={step.expectedOutput}
701
+ placeholder="What should happen…"
702
+ />
703
+ </div>
704
+ </div>
705
+ <button class="icon-btn danger small" on:click={() => removeStep(i)}>
706
+ <svg width="11" height="11" viewBox="0 0 14 14" fill="none"
707
+ ><path
708
+ d="M1 1l12 12M13 1L1 13"
709
+ stroke="currentColor"
710
+ stroke-width="1.5"
711
+ stroke-linecap="round"
712
+ /></svg
713
+ >
714
+ </button>
715
+ </div>
716
+ {/each}
717
+ {/if}
718
+ </div>
719
+ {:else if selectedCase.steps && selectedCase.steps.length > 0}
720
+ <div class="steps-table">
721
+ <div class="steps-table-head">
722
+ <span class="col-num">#</span>
723
+ <span class="col-action">Action</span>
724
+ <span class="col-data">Test Data</span>
725
+ <span class="col-expected">Expected Output</span>
726
+ </div>
727
+ {#each selectedCase.steps as step, i}
728
+ <div class="steps-table-row">
729
+ <span class="col-num">{i + 1}</span>
730
+ <span class="col-action">{step.action}</span>
731
+ <span class="col-data">{step.testData || '—'}</span>
732
+ <span class="col-expected">{step.expectedOutput || '—'}</span>
733
+ </div>
734
+ {/each}
735
+ </div>
736
+ {:else}
737
+ <p class="no-steps">No steps defined. Click "Edit Steps" to add them.</p>
738
+ {/if}
739
+ </div>
740
+ {:else}
741
+ <div class="history-section" transition:fly={{ y: 4, duration: 140 }}>
742
+ {#if !selectedCase.history || selectedCase.history.length === 0}
743
+ <p class="no-steps">
744
+ No history yet. Results appear after test runs or automated builds.
745
+ </p>
746
+ {:else}
747
+ <div class="history-list">
748
+ {#each selectedCase.history as h (h.id)}
749
+ <div class="history-row">
750
+ <span class="result-badge {resultClass(h.result)}">{h.result}</span>
751
+ <div class="history-info">
752
+ {#if h.source === 'automated' && h.report?.id}
753
+ <a
754
+ href="/reports/{h.report.id}"
755
+ target="_blank"
756
+ rel="noopener noreferrer"
757
+ class="history-source history-link">Automated run ↗</a
758
+ >
759
+ {:else if h.source === 'automated'}
760
+ <span class="history-source history-missing">Report not found</span>
761
+ {:else if h.run?.id}
762
+ <a
763
+ href="/test-repository/runs/{h.run.id}"
764
+ target="_blank"
765
+ rel="noopener noreferrer"
766
+ class="history-source history-link">{h.run.title} ↗</a
767
+ >
768
+ {:else}
769
+ <span class="history-source history-missing">Run not found</span>
770
+ {/if}
771
+ <span class="history-by">{h.executedBy?.name ?? '—'}</span>
772
+ <span class="history-date">{new Date(h.executedAt).toLocaleString()}</span>
773
+ </div>
774
+ {#if h.notes}
775
+ <p class="history-notes">{h.notes}</p>
776
+ {/if}
777
+ </div>
778
+ {/each}
779
+ </div>
780
+ {/if}
781
+ </div>
782
+ {/if}
783
+ {/if}
784
+ </div>
785
+ {/if}
786
+ </div>
787
+ {/if}
788
+
789
+ <style>
790
+ .loading-state {
791
+ font-size: 0.875rem;
792
+ color: var(--text-muted);
793
+ padding: 3rem 0;
794
+ }
795
+
796
+ /* ── Breadcrumb ── */
797
+ .breadcrumb {
798
+ display: flex;
799
+ align-items: center;
800
+ gap: 0.5rem;
801
+ font-size: 0.8125rem;
802
+ color: var(--text-muted);
803
+ margin-bottom: 1.5rem;
804
+ }
805
+
806
+ .bc-link {
807
+ color: var(--accent);
808
+ text-decoration: none;
809
+ }
810
+ .bc-link:hover {
811
+ text-decoration: underline;
812
+ }
813
+ .bc-sep {
814
+ color: var(--border);
815
+ }
816
+ .bc-current {
817
+ color: var(--text-muted);
818
+ }
819
+
820
+ /* ── Suite header ── */
821
+ .suite-header {
822
+ margin-bottom: 2rem;
823
+ padding-bottom: 1.5rem;
824
+ border-bottom: 1px solid var(--border);
825
+ }
826
+
827
+ .suite-title-row {
828
+ display: flex;
829
+ align-items: center;
830
+ gap: 0.625rem;
831
+ margin-bottom: 0.5rem;
832
+ flex-wrap: wrap;
833
+ }
834
+
835
+ .suite-title {
836
+ font-size: 1.75rem;
837
+ font-weight: 600;
838
+ color: var(--text);
839
+ }
840
+
841
+ .suite-desc {
842
+ font-size: 0.875rem;
843
+ color: var(--text-muted);
844
+ margin-bottom: 0.5rem;
845
+ line-height: 1.5;
846
+ }
847
+
848
+ .suite-meta {
849
+ display: flex;
850
+ align-items: center;
851
+ gap: 0.5rem;
852
+ font-size: 0.8125rem;
853
+ color: var(--text-muted);
854
+ }
855
+
856
+ .meta-sep {
857
+ color: var(--border);
858
+ }
859
+
860
+ /* ── Workspace ── */
861
+ .workspace {
862
+ display: grid;
863
+ grid-template-columns: 1fr;
864
+ gap: 1.5rem;
865
+ align-items: start;
866
+ }
867
+
868
+ .workspace.split {
869
+ grid-template-columns: 320px 1fr;
870
+ }
871
+
872
+ /* ── Cases panel ── */
873
+ .cases-panel {
874
+ }
875
+
876
+ .panel-toolbar {
877
+ display: flex;
878
+ align-items: center;
879
+ justify-content: space-between;
880
+ margin-bottom: 0.875rem;
881
+ }
882
+
883
+ .panel-title {
884
+ font-size: 0.9375rem;
885
+ font-weight: 500;
886
+ color: var(--text);
887
+ }
888
+
889
+ /* ── Case search ── */
890
+ .case-search {
891
+ position: relative;
892
+ display: flex;
893
+ align-items: center;
894
+ margin-bottom: 0.625rem;
895
+ }
896
+
897
+ .case-search-icon {
898
+ position: absolute;
899
+ left: 0.6rem;
900
+ color: var(--text-muted);
901
+ pointer-events: none;
902
+ }
903
+
904
+ .case-search-input {
905
+ width: 100%;
906
+ height: 30px;
907
+ padding: 0 1.75rem 0 2rem;
908
+ font-family: var(--font-body);
909
+ font-size: 0.8125rem;
910
+ color: var(--text);
911
+ background: var(--bg);
912
+ border: 1px solid var(--border);
913
+ border-radius: var(--radius-sm);
914
+ outline: none;
915
+ transition: border-color var(--duration-fast);
916
+ }
917
+
918
+ .case-search-input::placeholder {
919
+ color: var(--text-muted);
920
+ }
921
+ .case-search-input:focus {
922
+ border-color: var(--accent);
923
+ }
924
+ .case-search-input::-webkit-search-cancel-button {
925
+ display: none;
926
+ }
927
+
928
+ .case-search-clear {
929
+ position: absolute;
930
+ right: 0.5rem;
931
+ display: flex;
932
+ align-items: center;
933
+ justify-content: center;
934
+ width: 18px;
935
+ height: 18px;
936
+ background: none;
937
+ border: none;
938
+ cursor: pointer;
939
+ color: var(--text-muted);
940
+ padding: 0;
941
+ }
942
+
943
+ .case-search-clear:hover {
944
+ color: var(--text);
945
+ }
946
+
947
+ .case-no-results {
948
+ font-size: 0.8125rem;
949
+ color: var(--text-muted);
950
+ padding: 1rem 0.25rem;
951
+ }
952
+
953
+ .case-list {
954
+ display: flex;
955
+ flex-direction: column;
956
+ gap: 0.375rem;
957
+ }
958
+
959
+ .cases-pagination {
960
+ margin-top: 0.875rem;
961
+ }
962
+
963
+ /* ── Case sort bar ── */
964
+ .case-sort-bar {
965
+ display: flex;
966
+ align-items: center;
967
+ gap: 0.3rem;
968
+ margin-bottom: 0.625rem;
969
+ flex-wrap: wrap;
970
+ }
971
+
972
+ .case-sort-chip {
973
+ display: flex;
974
+ align-items: center;
975
+ gap: 0.2rem;
976
+ height: 24px;
977
+ padding: 0 0.5rem;
978
+ font-family: var(--font-body);
979
+ font-size: 0.75rem;
980
+ color: var(--text-muted);
981
+ background: var(--bg);
982
+ border: 1px solid var(--border);
983
+ border-radius: 100px;
984
+ cursor: pointer;
985
+ transition:
986
+ color var(--duration-fast),
987
+ border-color var(--duration-fast),
988
+ background var(--duration-fast);
989
+ }
990
+
991
+ .case-sort-chip:hover {
992
+ color: var(--text);
993
+ border-color: var(--accent);
994
+ }
995
+
996
+ .case-sort-chip.active {
997
+ color: var(--accent);
998
+ border-color: var(--accent);
999
+ background: var(--accent-soft);
1000
+ }
1001
+
1002
+ .case-sort-dir {
1003
+ font-size: 0.65rem;
1004
+ }
1005
+
1006
+ .case-row {
1007
+ display: flex;
1008
+ flex-direction: column;
1009
+ gap: 0.25rem;
1010
+ width: 100%;
1011
+ text-align: left;
1012
+ background: var(--bg-elevated);
1013
+ border: 1px solid var(--border);
1014
+ border-radius: var(--radius-md);
1015
+ padding: 0.75rem;
1016
+ cursor: pointer;
1017
+ transition:
1018
+ border-color var(--duration-fast),
1019
+ background var(--duration-fast);
1020
+ }
1021
+
1022
+ .case-row:hover {
1023
+ border-color: var(--accent);
1024
+ }
1025
+
1026
+ .case-row.selected {
1027
+ border-color: var(--accent);
1028
+ background: var(--accent-soft);
1029
+ }
1030
+
1031
+ .case-row-top {
1032
+ display: flex;
1033
+ align-items: center;
1034
+ gap: 0.375rem;
1035
+ }
1036
+
1037
+ .case-row-actions {
1038
+ margin-left: auto;
1039
+ opacity: 0;
1040
+ transition: opacity var(--duration-fast);
1041
+ }
1042
+
1043
+ .case-row:hover .case-row-actions {
1044
+ opacity: 1;
1045
+ }
1046
+
1047
+ .case-title {
1048
+ font-size: 0.875rem;
1049
+ color: var(--text);
1050
+ line-height: 1.4;
1051
+ }
1052
+
1053
+ .case-steps {
1054
+ font-size: 0.7rem;
1055
+ color: var(--text-muted);
1056
+ }
1057
+
1058
+ /* ── Detail panel ── */
1059
+ .detail-panel {
1060
+ background: var(--bg-elevated);
1061
+ border: 1px solid var(--border);
1062
+ border-radius: var(--radius-md);
1063
+ overflow-y: auto;
1064
+ position: sticky;
1065
+ top: calc(56px + 1.5rem);
1066
+ max-height: calc(100vh - 56px - 3rem);
1067
+ padding-bottom: 1.5rem;
1068
+ }
1069
+
1070
+ .detail-loading {
1071
+ padding: 2rem;
1072
+ font-size: 0.875rem;
1073
+ color: var(--text-muted);
1074
+ }
1075
+
1076
+ .detail-header {
1077
+ padding: 1.25rem;
1078
+ border-bottom: 1px solid var(--border);
1079
+ }
1080
+
1081
+ .detail-title-row {
1082
+ display: flex;
1083
+ align-items: center;
1084
+ gap: 0.5rem;
1085
+ flex-wrap: wrap;
1086
+ margin-bottom: 0.75rem;
1087
+ }
1088
+
1089
+ .detail-header-actions {
1090
+ margin-left: auto;
1091
+ display: flex;
1092
+ align-items: center;
1093
+ gap: 0.25rem;
1094
+ }
1095
+
1096
+ .detail-case-title {
1097
+ font-size: 1rem;
1098
+ font-weight: 500;
1099
+ color: var(--text);
1100
+ margin-bottom: 0.375rem;
1101
+ }
1102
+
1103
+ .detail-case-desc {
1104
+ font-size: 0.8125rem;
1105
+ color: var(--text-muted);
1106
+ line-height: 1.5;
1107
+ margin-bottom: 0.375rem;
1108
+ }
1109
+
1110
+ .detail-meta {
1111
+ font-size: 0.75rem;
1112
+ color: var(--text-muted);
1113
+ }
1114
+
1115
+ .detail-tabs {
1116
+ display: flex;
1117
+ border-bottom: 1px solid var(--border);
1118
+ }
1119
+
1120
+ .detail-tab {
1121
+ padding: 0.625rem 1rem;
1122
+ font-family: var(--font-body);
1123
+ font-size: 0.8125rem;
1124
+ font-weight: 400;
1125
+ color: var(--text-muted);
1126
+ background: none;
1127
+ border: none;
1128
+ border-bottom: 2px solid transparent;
1129
+ cursor: pointer;
1130
+ transition:
1131
+ color var(--duration-fast),
1132
+ border-color var(--duration-fast);
1133
+ margin-bottom: -1px;
1134
+ }
1135
+
1136
+ .detail-tab:hover {
1137
+ color: var(--text);
1138
+ }
1139
+ .detail-tab.active {
1140
+ color: var(--accent);
1141
+ border-bottom-color: var(--accent);
1142
+ }
1143
+
1144
+ /* ── Steps ── */
1145
+ .steps-section,
1146
+ .history-section {
1147
+ padding: 1rem 1.25rem;
1148
+ }
1149
+
1150
+ .steps-toolbar {
1151
+ display: flex;
1152
+ align-items: center;
1153
+ gap: 0.5rem;
1154
+ margin-bottom: 0.875rem;
1155
+ }
1156
+
1157
+ .add-step-btn {
1158
+ margin-left: auto;
1159
+ font-size: 0.8125rem;
1160
+ color: var(--accent);
1161
+ background: none;
1162
+ border: none;
1163
+ cursor: pointer;
1164
+ padding: 0.25rem 0.5rem;
1165
+ border-radius: var(--radius-sm);
1166
+ transition: background var(--duration-fast);
1167
+ }
1168
+
1169
+ .add-step-btn:hover {
1170
+ background: var(--accent-soft);
1171
+ }
1172
+
1173
+ .no-steps {
1174
+ font-size: 0.8125rem;
1175
+ color: var(--text-muted);
1176
+ padding: 1rem 0;
1177
+ }
1178
+
1179
+ .steps-table {
1180
+ font-size: 0.8125rem;
1181
+ border: 1px solid var(--border);
1182
+ border-radius: var(--radius-sm);
1183
+ overflow: hidden;
1184
+ }
1185
+
1186
+ .steps-table-head,
1187
+ .steps-table-row {
1188
+ display: grid;
1189
+ grid-template-columns: 28px 1fr 1fr 1fr;
1190
+ gap: 0.5rem;
1191
+ padding: 0.5rem 0.75rem;
1192
+ align-items: start;
1193
+ }
1194
+
1195
+ .steps-table-head {
1196
+ background: var(--bg-subtle);
1197
+ font-size: 0.7rem;
1198
+ font-weight: 500;
1199
+ text-transform: uppercase;
1200
+ letter-spacing: 0.05em;
1201
+ color: var(--text-muted);
1202
+ }
1203
+
1204
+ .steps-table-row {
1205
+ border-top: 1px solid var(--border);
1206
+ color: var(--text);
1207
+ line-height: 1.5;
1208
+ }
1209
+
1210
+ .col-num {
1211
+ color: var(--text-muted);
1212
+ }
1213
+
1214
+ .steps-editor {
1215
+ display: flex;
1216
+ flex-direction: column;
1217
+ gap: 0.5rem;
1218
+ }
1219
+
1220
+ .step-editor-row {
1221
+ display: flex;
1222
+ align-items: flex-end;
1223
+ gap: 0.5rem;
1224
+ }
1225
+
1226
+ .step-num {
1227
+ font-size: 0.75rem;
1228
+ color: var(--text-muted);
1229
+ min-width: 20px;
1230
+ padding-bottom: 0.5rem;
1231
+ }
1232
+
1233
+ .step-editor-fields {
1234
+ flex: 1;
1235
+ display: grid;
1236
+ grid-template-columns: 1fr 1fr 1fr;
1237
+ gap: 0.5rem;
1238
+ }
1239
+
1240
+ .step-field {
1241
+ display: flex;
1242
+ flex-direction: column;
1243
+ gap: 0.25rem;
1244
+ }
1245
+
1246
+ .step-field-label {
1247
+ font-size: 0.7rem;
1248
+ font-weight: 500;
1249
+ text-transform: uppercase;
1250
+ letter-spacing: 0.04em;
1251
+ color: var(--text-muted);
1252
+ }
1253
+
1254
+ .edit-case-form {
1255
+ display: flex;
1256
+ flex-direction: column;
1257
+ gap: 0.75rem;
1258
+ margin-top: 0.5rem;
1259
+ }
1260
+
1261
+ /* ── History ── */
1262
+ .history-list {
1263
+ display: flex;
1264
+ flex-direction: column;
1265
+ gap: 0.5rem;
1266
+ }
1267
+
1268
+ .history-row {
1269
+ display: flex;
1270
+ flex-direction: column;
1271
+ gap: 0.25rem;
1272
+ padding: 0.75rem;
1273
+ background: var(--bg-subtle);
1274
+ border-radius: var(--radius-sm);
1275
+ border: 1px solid var(--border);
1276
+ }
1277
+
1278
+ .history-info {
1279
+ display: flex;
1280
+ align-items: center;
1281
+ gap: 0.625rem;
1282
+ flex-wrap: wrap;
1283
+ }
1284
+
1285
+ .history-source {
1286
+ font-size: 0.8125rem;
1287
+ font-weight: 500;
1288
+ color: var(--text);
1289
+ }
1290
+
1291
+ .history-link {
1292
+ color: var(--accent);
1293
+ text-decoration: none;
1294
+ }
1295
+
1296
+ .history-link:hover {
1297
+ text-decoration: underline;
1298
+ }
1299
+
1300
+ .history-missing {
1301
+ font-style: italic;
1302
+ color: var(--text-muted);
1303
+ }
1304
+
1305
+ .history-by,
1306
+ .history-date {
1307
+ font-size: 0.75rem;
1308
+ color: var(--text-muted);
1309
+ }
1310
+
1311
+ .history-notes {
1312
+ font-size: 0.75rem;
1313
+ color: var(--text-muted);
1314
+ font-style: italic;
1315
+ }
1316
+
1317
+ /* ── Result / status badges ── */
1318
+ .result-badge {
1319
+ font-size: 0.68rem;
1320
+ font-weight: 600;
1321
+ text-transform: uppercase;
1322
+ letter-spacing: 0.05em;
1323
+ padding: 0.15rem 0.5rem;
1324
+ border-radius: 100px;
1325
+ align-self: flex-start;
1326
+ }
1327
+
1328
+ .result-badge.pass {
1329
+ background: var(--pass-soft);
1330
+ color: var(--pass);
1331
+ }
1332
+ .result-badge.fail {
1333
+ background: var(--fail-soft);
1334
+ color: var(--fail);
1335
+ }
1336
+ .result-badge.warn {
1337
+ background: var(--warn-soft);
1338
+ color: var(--warn);
1339
+ }
1340
+ .result-badge.muted {
1341
+ background: var(--bg-subtle);
1342
+ color: var(--text-muted);
1343
+ }
1344
+
1345
+ /* ── Shared ── */
1346
+ .id-chip {
1347
+ font-family: 'JetBrains Mono', monospace;
1348
+ font-weight: 600;
1349
+ color: var(--accent);
1350
+ background: var(--accent-soft);
1351
+ border-radius: 4px;
1352
+ letter-spacing: 0.02em;
1353
+ }
1354
+
1355
+ .id-chip.large {
1356
+ font-size: 0.75rem;
1357
+ padding: 0.1rem 0.45rem;
1358
+ }
1359
+ .id-chip.small {
1360
+ font-size: 0.65rem;
1361
+ padding: 0.08rem 0.35rem;
1362
+ }
1363
+
1364
+ .priority-badge {
1365
+ font-size: 0.68rem;
1366
+ font-weight: 500;
1367
+ border-radius: 100px;
1368
+ padding: 0.15rem 0.5rem;
1369
+ }
1370
+
1371
+ .priority-badge.small {
1372
+ font-size: 0.62rem;
1373
+ }
1374
+ .priority-badge.critical {
1375
+ background: var(--fail-soft);
1376
+ color: var(--fail);
1377
+ }
1378
+ .priority-badge.high {
1379
+ background: var(--warn-soft);
1380
+ color: var(--warn);
1381
+ }
1382
+ .priority-badge.medium {
1383
+ background: var(--node-soft);
1384
+ color: var(--node);
1385
+ }
1386
+ .priority-badge.low {
1387
+ background: var(--bg-subtle);
1388
+ color: var(--text-muted);
1389
+ }
1390
+
1391
+ .auto-badge {
1392
+ font-size: 0.62rem;
1393
+ font-weight: 500;
1394
+ background: var(--pass-soft);
1395
+ color: var(--pass);
1396
+ border-radius: 100px;
1397
+ padding: 0.15rem 0.5rem;
1398
+ }
1399
+
1400
+ .tag-chip {
1401
+ font-family: 'JetBrains Mono', monospace;
1402
+ font-size: 0.68rem;
1403
+ color: var(--text-muted);
1404
+ background: var(--bg-subtle);
1405
+ border: 1px solid var(--border);
1406
+ border-radius: 4px;
1407
+ padding: 0.08rem 0.4rem;
1408
+ }
1409
+
1410
+ .icon-btn {
1411
+ display: flex;
1412
+ align-items: center;
1413
+ justify-content: center;
1414
+ width: 28px;
1415
+ height: 28px;
1416
+ border: none;
1417
+ border-radius: var(--radius-sm);
1418
+ background: transparent;
1419
+ cursor: pointer;
1420
+ color: var(--text-muted);
1421
+ transition:
1422
+ background var(--duration-fast),
1423
+ color var(--duration-fast);
1424
+ }
1425
+
1426
+ .icon-btn.small {
1427
+ width: 24px;
1428
+ height: 24px;
1429
+ }
1430
+ .icon-btn:hover {
1431
+ background: var(--bg-subtle);
1432
+ color: var(--text);
1433
+ }
1434
+ .icon-btn.danger:hover {
1435
+ background: var(--fail-soft);
1436
+ color: var(--fail);
1437
+ }
1438
+
1439
+ .form-fields {
1440
+ display: flex;
1441
+ flex-direction: column;
1442
+ gap: 0.875rem;
1443
+ }
1444
+ .field {
1445
+ display: flex;
1446
+ flex-direction: column;
1447
+ gap: 0.375rem;
1448
+ }
1449
+ .field-row {
1450
+ display: grid;
1451
+ grid-template-columns: 1fr 1fr;
1452
+ gap: 0.75rem;
1453
+ }
1454
+ .field-label {
1455
+ font-size: 0.8125rem;
1456
+ font-weight: 500;
1457
+ color: var(--text);
1458
+ display: flex;
1459
+ align-items: baseline;
1460
+ gap: 0.5rem;
1461
+ }
1462
+ .field-hint {
1463
+ font-size: 0.75rem;
1464
+ font-weight: 400;
1465
+ color: var(--text-muted);
1466
+ }
1467
+ .field-textarea {
1468
+ height: auto;
1469
+ resize: vertical;
1470
+ }
1471
+ .mono {
1472
+ font-family: 'JetBrains Mono', monospace;
1473
+ font-size: 0.8125rem !important;
1474
+ }
1475
+ .form-error {
1476
+ font-size: 0.8125rem;
1477
+ color: var(--fail);
1478
+ }
1479
+ .modal-actions {
1480
+ display: flex;
1481
+ gap: 0.5rem;
1482
+ padding-top: 0.25rem;
1483
+ }
1484
+
1485
+ @media (max-width: 768px) {
1486
+ .workspace.split {
1487
+ grid-template-columns: 1fr;
1488
+ }
1489
+ }
1490
+ </style>