@runwisp/ui 0.13.0 → 0.15.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 (69) hide show
  1. package/package.json +39 -47
  2. package/src/astro/CTABand.astro +19 -12
  3. package/src/astro/Hero.astro +3 -3
  4. package/src/astro/SiteFooter.astro +4 -4
  5. package/src/astro/SiteHeader.astro +4 -4
  6. package/src/lib/actions/portal.ts +0 -3
  7. package/src/lib/assets/runwisp-logo.svg +1 -8
  8. package/src/lib/components/AccordionItem.svelte +4 -10
  9. package/src/lib/components/Alert.svelte +11 -11
  10. package/src/lib/components/Avatar.svelte +1 -1
  11. package/src/lib/components/Badge.svelte +13 -13
  12. package/src/lib/components/Breadcrumb.svelte +3 -3
  13. package/src/lib/components/Button.svelte +2 -2
  14. package/src/lib/components/Card.svelte +4 -4
  15. package/src/lib/components/Checkbox.svelte +12 -10
  16. package/src/lib/components/CodeBlock.svelte +7 -13
  17. package/src/lib/components/CronInput.svelte +12 -15
  18. package/src/lib/components/DataGrid.svelte +14 -13
  19. package/src/lib/components/Drawer.svelte +9 -9
  20. package/src/lib/components/Dropdown.svelte +12 -20
  21. package/src/lib/components/DurationInput.svelte +10 -14
  22. package/src/lib/components/EmptyState.svelte +1 -1
  23. package/src/lib/components/ErrorState.svelte +4 -4
  24. package/src/lib/components/FeatureCard.svelte +4 -4
  25. package/src/lib/components/FormField.svelte +4 -6
  26. package/src/lib/components/Heading.svelte +4 -1
  27. package/src/lib/components/Input.svelte +10 -12
  28. package/src/lib/components/Kbd.svelte +1 -1
  29. package/src/lib/components/Link.svelte +2 -3
  30. package/src/lib/components/LinkButton.svelte +2 -2
  31. package/src/lib/components/LogConsole.svelte +22 -21
  32. package/src/lib/components/Logo.svelte +3 -1
  33. package/src/lib/components/Modal.svelte +9 -11
  34. package/src/lib/components/PageContainer.svelte +1 -1
  35. package/src/lib/components/PageHeader.svelte +1 -1
  36. package/src/lib/components/Pagination.svelte +4 -4
  37. package/src/lib/components/Popover.svelte +5 -7
  38. package/src/lib/components/Progress.svelte +6 -4
  39. package/src/lib/components/Prose.svelte +4 -4
  40. package/src/lib/components/Radio.svelte +11 -10
  41. package/src/lib/components/RadioGroup.svelte +3 -5
  42. package/src/lib/components/Select.svelte +23 -36
  43. package/src/lib/components/Separator.svelte +4 -1
  44. package/src/lib/components/Skeleton.svelte +6 -6
  45. package/src/lib/components/StatusIndicator.svelte +7 -18
  46. package/src/lib/components/Table.svelte +1 -1
  47. package/src/lib/components/TableCell.svelte +1 -1
  48. package/src/lib/components/TableRow.svelte +1 -5
  49. package/src/lib/components/Tabs.svelte +10 -13
  50. package/src/lib/components/TaskCard.svelte +7 -4
  51. package/src/lib/components/Textarea.svelte +10 -11
  52. package/src/lib/components/ThemeToggle.svelte +1 -1
  53. package/src/lib/components/ToastContainer.svelte +3 -8
  54. package/src/lib/components/Toggle.svelte +2 -3
  55. package/src/lib/components/Tooltip.svelte +12 -10
  56. package/src/lib/components/button-styles.ts +17 -18
  57. package/src/lib/components/dashboard/RunDetailPanel.svelte +160 -249
  58. package/src/lib/components/dashboard/RunFilterPopover.svelte +31 -25
  59. package/src/lib/components/dashboard/RunsList.svelte +55 -50
  60. package/src/lib/components/dashboard/run-filters.test.ts +31 -33
  61. package/src/lib/components/dashboard/run-filters.ts +33 -33
  62. package/src/lib/components/dashboard/run-helpers.test.ts +42 -17
  63. package/src/lib/components/dashboard/run-helpers.ts +53 -17
  64. package/src/lib/components/dashboard/status-config.ts +9 -8
  65. package/src/lib/theme-tokens.css +179 -114
  66. package/src/lib/theme.css +49 -35
  67. package/src/lib/utils/error.ts +0 -3
  68. package/src/lib/utils/format.test.ts +2 -6
  69. package/src/lib/utils/format.ts +28 -3
@@ -47,10 +47,13 @@
47
47
  filters = $bindable(),
48
48
  showTask = false,
49
49
  tasks = [],
50
+ compact = false,
50
51
  }: {
51
52
  filters: RunsListFilters;
52
53
  showTask?: boolean;
53
54
  tasks?: { name: string }[];
55
+ // Icon-only trigger for narrow rails where a "Filter" label wouldn't fit.
56
+ compact?: boolean;
54
57
  } = $props();
55
58
 
56
59
  // The individual statuses behind the "Advanced" expander, grouped the way
@@ -62,12 +65,13 @@
62
65
 
63
66
  let advancedOpen = $state(false);
64
67
 
65
- const count = $derived(activeFilterCount(filters) + (showTask && filters.task_name ? 1 : 0));
68
+ const count = $derived(activeFilterCount(filters) + (showTask && filters.taskName ? 1 : 0));
66
69
 
67
- const headerClass = "text-xs font-semibold tracking-wide text-on-surface-muted uppercase";
68
- const labelClass = "text-2xs text-on-surface-faint uppercase";
70
+ const headerClass =
71
+ "font-mono text-xs font-semibold tracking-[0.14em] text-on-surface-muted uppercase";
72
+ const labelClass = "font-mono text-2xs tracking-[0.12em] text-on-surface-faint uppercase";
69
73
  const selectClass =
70
- "h-9 w-full appearance-none rounded-md border border-outline bg-surface-raised px-2.5 text-sm text-on-surface focus:border-ring focus:outline-none";
74
+ "h-9 w-full appearance-none rounded-[3px] border border-outline bg-surface-raised px-2.5 font-mono text-sm text-on-surface focus:border-ring focus:outline-none";
71
75
 
72
76
  function statusDot(status: RunStatus): string {
73
77
  return RUN_STATUS_CONFIG[status].dot.replace(" animate-pulse", "");
@@ -99,51 +103,53 @@
99
103
  // both captures the whole day.
100
104
 
101
105
  function setFrom(value: string) {
102
- filters = { ...filters, created_after: value ? dayStartIso(value) : undefined };
106
+ filters = { ...filters, createdAfter: value ? dayStartIso(value) : undefined };
103
107
  }
104
108
 
105
109
  function setTo(value: string) {
106
- filters = { ...filters, created_before: value ? dayEndIso(value) : undefined };
110
+ filters = { ...filters, createdBefore: value ? dayEndIso(value) : undefined };
107
111
  }
108
112
 
109
113
  // --- Other dimensions -------------------------------------------------
110
114
 
111
115
  function setTask(value: string) {
112
- filters = { ...filters, task_name: value || undefined };
116
+ filters = { ...filters, taskName: value || undefined };
113
117
  }
114
118
 
115
119
  function setTrigger(value: string) {
116
- filters = { ...filters, triggered_by: value || undefined };
120
+ filters = { ...filters, triggeredBy: value || undefined };
117
121
  }
118
122
 
119
123
  // Exit code: a free-form expression (`137`, `>100`, `>100 <150`) normalized
120
124
  // to an inclusive range at the wire. Edited in a local buffer for smooth
121
125
  // typing and committed on change; the popover content remounts on each open,
122
126
  // so the buffer re-seeds from `filters` every time it opens.
123
- let exitCodeInput = $state(filters.exit_code ?? "");
127
+ let exitCodeInput = $state(filters.exitCode ?? "");
124
128
  const exitCodeValid = $derived(isExitCodeExprValid(exitCodeInput));
125
129
 
126
130
  function commitExitCode() {
127
131
  const trimmed = exitCodeInput.trim();
128
- filters = { ...filters, exit_code: trimmed || undefined };
132
+ filters = { ...filters, exitCode: trimmed || undefined };
129
133
  }
130
134
 
131
135
  function setRetriesOnly(checked: boolean) {
132
- filters = { ...filters, retries_only: checked ? true : undefined };
136
+ filters = { ...filters, retriesOnly: checked ? true : undefined };
133
137
  }
134
138
  </script>
135
139
 
136
140
  <Popover placement="right-start" mobileSheet>
137
141
  {#snippet trigger()}
138
142
  <span
139
- class="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border px-2 text-xs font-medium transition-colors {count >
140
- 0
141
- ? 'border-wisp-300 bg-primary-soft text-primary-soft-text'
142
- : 'border-transparent text-on-surface-muted hover:bg-surface-sunken'}"
143
+ class="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-[3px] border font-mono text-xs font-medium {compact &&
144
+ count === 0
145
+ ? 'w-7 justify-center px-0'
146
+ : 'px-2'} {count > 0
147
+ ? 'border-primary-soft-border bg-primary-soft text-primary-soft-text'
148
+ : 'border-transparent text-on-surface-muted hover:border-outline-hover hover:bg-surface-sunken hover:text-primary'}"
143
149
  title="Filter runs"
144
150
  >
145
151
  <Funnel size={13} />
146
- Filter
152
+ {#if !compact}Filter{/if}
147
153
  {#if count > 0}
148
154
  <Badge variant="primary" size="sm" class="px-1.5 py-0">{count}</Badge>
149
155
  {/if}
@@ -167,14 +173,14 @@
167
173
  class="size-4 shrink-0 cursor-pointer rounded border-outline accent-primary"
168
174
  />
169
175
  <span class="size-2.5 shrink-0 rounded-full {bucket.dot}"></span>
170
- <span class="text-sm text-on-surface">{bucket.label}</span>
176
+ <span class="font-mono text-sm text-on-surface">{bucket.label}</span>
171
177
  </label>
172
178
  {/each}
173
179
  </div>
174
180
 
175
181
  <details class="mt-0.5" bind:open={advancedOpen}>
176
182
  <summary
177
- class="flex cursor-pointer items-center gap-1 text-2xs font-medium text-on-surface-muted transition-colors select-none marker:content-none hover:text-on-surface [&::-webkit-details-marker]:hidden"
183
+ class="flex cursor-pointer items-center gap-1 font-mono text-2xs font-medium text-on-surface-muted select-none marker:content-none hover:text-on-surface [&::-webkit-details-marker]:hidden"
178
184
  >
179
185
  <ChevronRight size={12} class={advancedOpen ? "rotate-90" : ""} />
180
186
  Advanced — pick exact statuses
@@ -185,7 +191,7 @@
185
191
  <div class="grid grid-cols-2 gap-x-2 gap-y-1">
186
192
  {#each group.values as status (status)}
187
193
  <label
188
- class="flex cursor-pointer items-center gap-1.5 truncate text-xs"
194
+ class="flex cursor-pointer items-center gap-1.5 truncate font-mono text-xs"
189
195
  >
190
196
  <input
191
197
  type="checkbox"
@@ -206,7 +212,7 @@
206
212
  </details>
207
213
  </section>
208
214
 
209
- <!-- Date range (filters on created_at). Independent From/To bounds: a
215
+ <!-- Date range (filters on createdAt). Independent From/To bounds: a
210
216
  bare date is that day's 00:00 (From) or its end (To), so either edge
211
217
  applies on its own and the same date in both captures the day. -->
212
218
  <section class="flex flex-col gap-2">
@@ -216,7 +222,7 @@
216
222
  <span class={labelClass}>From</span>
217
223
  <input
218
224
  type="date"
219
- value={filters.created_after ? isoToDayInput(filters.created_after) : ""}
225
+ value={filters.createdAfter ? isoToDayInput(filters.createdAfter) : ""}
220
226
  onchange={(e) => setFrom(e.currentTarget.value)}
221
227
  class={selectClass}
222
228
  />
@@ -225,7 +231,7 @@
225
231
  <span class={labelClass}>To</span>
226
232
  <input
227
233
  type="date"
228
- value={filters.created_before ? isoToDayInput(filters.created_before) : ""}
234
+ value={filters.createdBefore ? isoToDayInput(filters.createdBefore) : ""}
229
235
  onchange={(e) => setTo(e.currentTarget.value)}
230
236
  class={selectClass}
231
237
  />
@@ -239,7 +245,7 @@
239
245
  <span class={headerClass}>Task</span>
240
246
  <select
241
247
  class={selectClass}
242
- value={filters.task_name ?? ""}
248
+ value={filters.taskName ?? ""}
243
249
  onchange={(e) => setTask(e.currentTarget.value)}
244
250
  >
245
251
  <option value="">Any task</option>
@@ -255,7 +261,7 @@
255
261
  <span class={headerClass}>Triggered by</span>
256
262
  <select
257
263
  class={selectClass}
258
- value={filters.triggered_by ?? ""}
264
+ value={filters.triggeredBy ?? ""}
259
265
  onchange={(e) => setTrigger(e.currentTarget.value)}
260
266
  >
261
267
  <option value="">Any trigger</option>
@@ -290,7 +296,7 @@
290
296
  <span class={headerClass}>Retries</span>
291
297
  <Toggle
292
298
  size="sm"
293
- checked={Boolean(filters.retries_only)}
299
+ checked={Boolean(filters.retriesOnly)}
294
300
  label="Only retried runs"
295
301
  onchange={setRetriesOnly}
296
302
  />
@@ -175,7 +175,7 @@
175
175
  if (run.status === "running") return "live";
176
176
  if (run.status === "pending") return "queued";
177
177
  if (displayed === "failed" || displayed === "crashed") {
178
- return "exit " + String(run.exit_code);
178
+ return "exit " + String(run.exitCode);
179
179
  }
180
180
  return runDuration(run) ?? "—";
181
181
  }
@@ -277,30 +277,30 @@
277
277
  // (page-injected or popover-set) always applies.
278
278
  function buildSelectorFilter(): NonNullable<RunSelector["filter"]> {
279
279
  const filter: NonNullable<RunSelector["filter"]> = {};
280
- if (taskNameFilter) filter.task_name = taskNameFilter;
281
- else if (filters.task_name) filter.task_name = filters.task_name;
280
+ if (taskNameFilter) filter.taskName = taskNameFilter;
281
+ else if (filters.taskName) filter.taskName = filters.taskName;
282
282
  if (!showFilters) return filter;
283
283
  if (filters.statuses.length > 0) filter.status = filters.statuses.join(",");
284
284
  const query = filters.search.trim();
285
285
  if (query) filter.search = query;
286
- if (filters.created_after) filter.created_after = filters.created_after;
287
- if (filters.created_before) filter.created_before = filters.created_before;
288
- if (filters.triggered_by) filter.triggered_by = filters.triggered_by;
289
- const exit = exitCodeRange(filters.exit_code);
290
- if (exit.min !== undefined) filter.exit_code_min = exit.min;
291
- if (exit.max !== undefined) filter.exit_code_max = exit.max;
292
- if (filters.retries_only) filter.retries_only = true;
286
+ if (filters.createdAfter) filter.createdAfter = filters.createdAfter;
287
+ if (filters.createdBefore) filter.createdBefore = filters.createdBefore;
288
+ if (filters.triggeredBy) filter.triggeredBy = filters.triggeredBy;
289
+ const exit = exitCodeRange(filters.exitCode);
290
+ if (exit.min !== undefined) filter.exitCodeMin = exit.min;
291
+ if (exit.max !== undefined) filter.exitCodeMax = exit.max;
292
+ if (filters.retriesOnly) filter.retriesOnly = true;
293
293
  return filter;
294
294
  }
295
295
 
296
296
  function buildSelector(): RunSelector {
297
297
  if (!selectAllMode) {
298
- return { match_all: false, ids: [...explicitIds] };
298
+ return { matchAll: false, ids: [...explicitIds] };
299
299
  }
300
300
  return {
301
- match_all: true,
301
+ matchAll: true,
302
302
  filter: buildSelectorFilter(),
303
- except_ids: [...exceptIds],
303
+ exceptIds: [...exceptIds],
304
304
  };
305
305
  }
306
306
 
@@ -319,19 +319,19 @@
319
319
  case "time":
320
320
  return timeChipLabel();
321
321
  case "task":
322
- return filters.task_name ?? "";
323
- case "triggered_by":
324
- return "Trigger: " + triggerDescription(filters.triggered_by ?? "");
325
- case "exit_code":
326
- return exitCodeChipLabel(filters.exit_code);
322
+ return filters.taskName ?? "";
323
+ case "triggeredBy":
324
+ return "Trigger: " + triggerDescription(filters.triggeredBy ?? "");
325
+ case "exitCode":
326
+ return exitCodeChipLabel(filters.exitCode);
327
327
  case "retries":
328
328
  return "Retries only";
329
329
  }
330
330
  }
331
331
 
332
332
  function timeChipLabel(): string {
333
- const after = filters.created_after;
334
- const before = filters.created_before;
333
+ const after = filters.createdAfter;
334
+ const before = filters.createdBefore;
335
335
  if (isWholeDay(after, before) && after) return `On ${formatCalendarDate(after)}`;
336
336
  if (after && before) return `${formatDateTime(after)} – ${formatDateTime(before)}`;
337
337
  if (after) return `Since ${formatDateTime(after)}`;
@@ -350,9 +350,9 @@
350
350
  const sel = buildSelector();
351
351
  // Narrow the selector to the affected predicate when in explicit mode
352
352
  // so we don't ask the server to operate on rows the UI excluded.
353
- const narrowed: RunSelector = sel.match_all
353
+ const narrowed: RunSelector = sel.matchAll
354
354
  ? sel
355
- : { match_all: false, ids: affected.map((r) => r.id) };
355
+ : { matchAll: false, ids: affected.map((r) => r.id) };
356
356
  handler(narrowed, affected);
357
357
  clearSelection();
358
358
  }
@@ -368,7 +368,7 @@
368
368
  // reliably re-triggers its fetch effect.
369
369
  filters = {
370
370
  ...filters,
371
- sort_direction: filters.sort_direction === "asc" ? "desc" : "asc",
371
+ sortDirection: filters.sortDirection === "asc" ? "desc" : "asc",
372
372
  };
373
373
  }
374
374
 
@@ -382,7 +382,7 @@
382
382
  <div
383
383
  class={flush
384
384
  ? "flex h-full w-full flex-col overflow-hidden border-b border-outline bg-surface md:w-[300px] md:shrink-0 md:border-r md:border-b-0"
385
- : "flex flex-col overflow-hidden rounded-xl border border-outline bg-surface-raised shadow-sm md:col-span-4 lg:col-span-3"}
385
+ : "flex flex-col overflow-hidden rounded-[4px] border border-outline bg-surface-raised shadow-sm md:col-span-4 lg:col-span-3"}
386
386
  >
387
387
  <div
388
388
  class="flex shrink-0 items-center gap-2 border-b px-3 py-2 {hasSelection
@@ -408,7 +408,7 @@
408
408
  {/if}
409
409
 
410
410
  {#if hasSelection}
411
- <span class="text-xs font-medium text-on-surface">
411
+ <span class="font-mono text-xs font-medium text-on-surface tabular-nums">
412
412
  {selectionCount} selected
413
413
  </span>
414
414
  <div class="ml-auto flex items-center gap-1">
@@ -451,10 +451,14 @@
451
451
  {/if}
452
452
  </div>
453
453
  {:else}
454
- <span class="text-xs font-semibold tracking-wider text-on-surface-muted uppercase">
454
+ <span
455
+ class="shrink-0 font-mono text-xs font-semibold tracking-[0.14em] whitespace-nowrap text-on-surface-muted uppercase"
456
+ >
455
457
  {headerLabel}
456
458
  </span>
457
- <span class="text-xs text-on-surface-faint">
459
+ <span
460
+ class="shrink-0 font-mono text-xs whitespace-nowrap text-on-surface-faint tabular-nums"
461
+ >
458
462
  {#if outputSearchActive}
459
463
  {matchedRuns.length} of {items.length}
460
464
  {:else}
@@ -464,7 +468,7 @@
464
468
  </span>
465
469
  <div class="ml-auto flex items-center gap-1">
466
470
  {#if showFilters}
467
- <RunFilterPopover bind:filters {showTask} {tasks} />
471
+ <RunFilterPopover bind:filters {showTask} {tasks} compact={flush} />
468
472
  {/if}
469
473
  <Button
470
474
  variant="ghost"
@@ -476,7 +480,7 @@
476
480
  {#snippet icon()}
477
481
  <ArrowUpDown
478
482
  size={14}
479
- class="text-on-surface-muted {filters.sort_direction === 'asc'
483
+ class="text-on-surface-muted {filters.sortDirection === 'asc'
480
484
  ? 'rotate-180'
481
485
  : ''}"
482
486
  />
@@ -495,13 +499,13 @@
495
499
  >
496
500
  {#each filterChips as chip (chip.dimension)}
497
501
  <span
498
- class="inline-flex items-center gap-1 rounded-full bg-surface-raised py-0.5 pr-1 pl-2 text-2xs font-medium text-on-surface-muted ring-1 ring-outline-faint ring-inset"
502
+ class="inline-flex items-center gap-1 rounded-[3px] border border-primary-soft-border bg-primary-soft py-0.5 pr-1 pl-2 font-mono text-2xs font-medium text-primary-soft-text"
499
503
  >
500
504
  {chip.label}
501
505
  <button
502
506
  type="button"
503
507
  onclick={() => removeChip(chip.dimension)}
504
- class="flex size-3.5 items-center justify-center rounded-full text-on-surface-faint transition-colors hover:bg-surface-sunken hover:text-on-surface"
508
+ class="flex size-3.5 items-center justify-center rounded-[3px] text-primary-soft-text/70 hover:bg-primary/15 hover:text-primary-soft-text"
505
509
  aria-label="Remove {chip.label} filter"
506
510
  >
507
511
  <X size={11} />
@@ -519,7 +523,7 @@
519
523
  <!-- Searching: shimmer placeholders shaped like result rows. -->
520
524
  <div class="flex flex-col gap-0.5" aria-busy="true" aria-label="Searching output">
521
525
  {#each [0, 1, 2, 3, 4] as i (i)}
522
- <div class="flex items-center gap-2.5 rounded-[10px] px-3 py-2.5">
526
+ <div class="flex items-center gap-2.5 rounded-[3px] px-3 py-2.5">
523
527
  <span
524
528
  class="size-[9px] shrink-0 animate-pulse rounded-full bg-surface-sunken"
525
529
  ></span>
@@ -535,7 +539,7 @@
535
539
  {:else if matchedRuns.length === 0}
536
540
  <div class="px-4 py-8 text-center text-xs leading-relaxed text-on-surface-muted">
537
541
  No output matches
538
- <b class="text-on-surface">“{outputQuery.trim()}”</b>.<br />
542
+ <b class="font-mono text-on-surface">“{outputQuery.trim()}”</b>.<br />
539
543
  Try a different term.
540
544
  </div>
541
545
  {:else}
@@ -601,7 +605,7 @@
601
605
  : ''} {bulkActions
602
606
  ? selectionActive
603
607
  ? 'opacity-0'
604
- : 'transition-opacity group-hover/row:opacity-0'
608
+ : 'group-hover/row:opacity-0'
605
609
  : ''}"
606
610
  aria-hidden="true"
607
611
  ></span>
@@ -622,8 +626,8 @@
622
626
  checked={isRowSelected(run.id)}
623
627
  onchange={() => toggleRow(run.id)}
624
628
  onclick={(e) => e.stopPropagation()}
625
- aria-label={`Select run from ${formatDateTime(run.start_at ?? run.created_at)}`}
626
- class="size-3.5 cursor-pointer rounded border-outline accent-primary opacity-0 transition-opacity {selectionActive
629
+ aria-label={`Select run from ${formatDateTime(run.startAt ?? run.createdAt)}`}
630
+ class="size-3.5 cursor-pointer rounded border-outline accent-primary opacity-0 {selectionActive
627
631
  ? 'opacity-100'
628
632
  : 'group-hover/row:opacity-100'}"
629
633
  />
@@ -635,15 +639,15 @@
635
639
  {@const config = getRunStatusConfig(dstatus)}
636
640
  {@const running = run.status === "running"}
637
641
  {@const spine = config.dot.replace(" animate-pulse", "")}
638
- {@const startedAt = run.start_at ?? run.created_at}
642
+ {@const startedAt = run.startAt ?? run.createdAt}
639
643
  {@const retry = runRetryLabel(run)}
640
- {@const suffix = instanceSuffix(run.instance_index, getInstanceCount(run.task_name))}
644
+ {@const suffix = instanceSuffix(run.instanceIndex, getInstanceCount(run.taskName))}
641
645
  <button
642
- class="btn-scale group relative w-full rounded-[10px] border text-left transition-all select-none {showTaskName
646
+ class="btn-scale group relative w-full rounded-[3px] border text-left select-none {showTaskName
643
647
  ? 'p-3'
644
648
  : 'px-3 py-[11px]'} {isActive
645
649
  ? 'border-outline bg-surface-raised shadow-sm'
646
- : 'border-transparent hover:bg-surface-sunken'}"
650
+ : 'border-transparent hover:border-outline-hover hover:bg-surface-sunken'}"
647
651
  onclick={() => selectRun(run.id)}
648
652
  onkeydown={(e) => e.key === "Enter" && selectRun(run.id)}
649
653
  >
@@ -655,22 +659,23 @@
655
659
  {@render statusDot(config.color, running)}
656
660
  <span class="flex min-w-0 flex-1 flex-col gap-0.5">
657
661
  <span class="flex items-center gap-1.5">
658
- <span class="truncate text-[13px] font-semibold text-on-surface">
659
- {run.task_name}{#if suffix}<span class="text-on-surface-muted"
662
+ <span class="truncate font-mono text-[13px] font-semibold text-on-surface">
663
+ {run.taskName}{#if suffix}<span class="text-on-surface-muted"
660
664
  >{suffix}</span
661
665
  >{/if}
662
666
  </span>
663
667
  <span class="shrink-0 text-on-surface-faint">·</span>
664
- <span class="shrink-0 text-[12px] font-semibold capitalize {config.color}"
668
+ <span
669
+ class="shrink-0 font-mono text-[12px] font-semibold capitalize {config.color}"
665
670
  >{dstatus}</span
666
671
  >
667
672
  </span>
668
673
  <span
669
- class="flex min-w-0 items-center gap-1.5 text-2xs text-on-surface-faint"
674
+ class="flex min-w-0 items-center gap-1.5 font-mono text-2xs text-on-surface-faint"
670
675
  title={formatFullDateTime(startedAt)}
671
676
  >
672
677
  <span class="truncate">{formatDateTime(startedAt)}</span>
673
- <span class="shrink-0">· {formatTriggeredByLabel(run.triggered_by)}</span>
678
+ <span class="shrink-0">· {formatTriggeredByLabel(run.triggeredBy)}</span>
674
679
  {#if retry}
675
680
  <span class="shrink-0 rounded bg-surface-sunken px-1 font-mono"
676
681
  >{retry}</span
@@ -695,13 +700,13 @@
695
700
  {@render statusDot(config.color, running)}
696
701
  <span class="flex min-w-0 flex-1 items-center gap-1.5 truncate">
697
702
  <span
698
- class="text-[12.5px] font-semibold tracking-tight text-on-surface"
703
+ class="font-mono text-[12.5px] font-semibold tracking-tight text-on-surface tabular-nums"
699
704
  title={formatFullDateTime(startedAt)}
700
705
  >
701
706
  {formatTimeHM(startedAt)} · {formatDayMonth(startedAt)}
702
707
  </span>
703
708
  <span class="text-on-surface-faint">·</span>
704
- <span class="text-[12.5px] font-semibold capitalize {config.color}"
709
+ <span class="font-mono text-[12.5px] font-semibold capitalize {config.color}"
705
710
  >{dstatus}</span
706
711
  >
707
712
  {#if suffix}
@@ -718,10 +723,10 @@
718
723
  {#if match}
719
724
  {@const hl = highlightParts(match.text, outputQuery)}
720
725
  <div
721
- class="mt-1.5 truncate rounded-md bg-surface-sunken px-2 py-1 font-mono text-2xs text-on-surface-muted"
726
+ class="mt-1.5 truncate rounded-[3px] border border-outline-faint bg-surface-sunken px-2 py-1 font-mono text-2xs text-on-surface-muted"
722
727
  >
723
728
  {hl.before}<mark
724
- class="rounded-sm bg-primary-soft px-0.5 text-primary-soft-text"
729
+ class="rounded-[3px] bg-primary-soft px-0.5 text-primary-soft-text"
725
730
  >{hl.match}</mark
726
731
  >{hl.after}
727
732
  </div>
@@ -729,7 +734,7 @@
729
734
  {/if}
730
735
 
731
736
  <div
732
- class="duration-normal absolute inset-y-2 left-[-6px] w-[3px] rounded-[3px] transition-all {spine} {isActive
737
+ class="absolute inset-y-2 left-[-6px] w-[3px] rounded-[3px] {spine} {isActive
733
738
  ? 'opacity-100'
734
739
  : 'opacity-0'}"
735
740
  aria-hidden="true"
@@ -47,7 +47,7 @@ describe("emptyRunFilters", () => {
47
47
  it("is a fully-open filter — no dimension active, newest-first", () => {
48
48
  const f = emptyRunFilters();
49
49
  expect(f.statuses).toEqual([]);
50
- expect(f.sort_direction).toBe("desc");
50
+ expect(f.sortDirection).toBe("desc");
51
51
  expect(activeFilterCount(f)).toBe(0);
52
52
  });
53
53
  });
@@ -75,56 +75,54 @@ describe("isNeedsAttention", () => {
75
75
  describe("dimensionActive / activeDimensions", () => {
76
76
  it("detects each dimension independently", () => {
77
77
  expect(dimensionActive(base({ statuses: ["failed"] }), "status")).toBe(true);
78
- expect(dimensionActive(base({ created_after: "2026-01-01T00:00:00Z" }), "time")).toBe(true);
79
- expect(dimensionActive(base({ created_before: "2026-01-01T00:00:00Z" }), "time")).toBe(
80
- true,
81
- );
82
- expect(dimensionActive(base({ task_name: "backup" }), "task")).toBe(true);
83
- expect(dimensionActive(base({ triggered_by: "cron" }), "triggered_by")).toBe(true);
84
- expect(dimensionActive(base({ exit_code: "137" }), "exit_code")).toBe(true);
85
- expect(dimensionActive(base({ exit_code: ">100" }), "exit_code")).toBe(true);
86
- expect(dimensionActive(base({ retries_only: true }), "retries")).toBe(true);
78
+ expect(dimensionActive(base({ createdAfter: "2026-01-01T00:00:00Z" }), "time")).toBe(true);
79
+ expect(dimensionActive(base({ createdBefore: "2026-01-01T00:00:00Z" }), "time")).toBe(true);
80
+ expect(dimensionActive(base({ taskName: "backup" }), "task")).toBe(true);
81
+ expect(dimensionActive(base({ triggeredBy: "cron" }), "triggeredBy")).toBe(true);
82
+ expect(dimensionActive(base({ exitCode: "137" }), "exitCode")).toBe(true);
83
+ expect(dimensionActive(base({ exitCode: ">100" }), "exitCode")).toBe(true);
84
+ expect(dimensionActive(base({ retriesOnly: true }), "retries")).toBe(true);
87
85
  });
88
86
 
89
87
  it("treats an exact exit code of 0 as active (distinct from absent)", () => {
90
- expect(dimensionActive(base({ exit_code: "0" }), "exit_code")).toBe(true);
88
+ expect(dimensionActive(base({ exitCode: "0" }), "exitCode")).toBe(true);
91
89
  });
92
90
 
93
91
  it("treats an empty or unparseable exit-code expression as inactive", () => {
94
- expect(dimensionActive(base({ exit_code: "" }), "exit_code")).toBe(false);
95
- expect(dimensionActive(base({ exit_code: "nonsense" }), "exit_code")).toBe(false);
92
+ expect(dimensionActive(base({ exitCode: "" }), "exitCode")).toBe(false);
93
+ expect(dimensionActive(base({ exitCode: "nonsense" }), "exitCode")).toBe(false);
96
94
  });
97
95
 
98
96
  it("lists active dimensions in display (most→least useful) order", () => {
99
97
  const f = base({
100
- retries_only: true,
98
+ retriesOnly: true,
101
99
  statuses: ["failed"],
102
- triggered_by: "cron",
103
- created_after: "2026-01-01T00:00:00Z",
100
+ triggeredBy: "cron",
101
+ createdAfter: "2026-01-01T00:00:00Z",
104
102
  });
105
- expect(activeDimensions(f)).toEqual(["status", "time", "triggered_by", "retries"]);
103
+ expect(activeDimensions(f)).toEqual(["status", "time", "triggeredBy", "retries"]);
106
104
  });
107
105
  });
108
106
 
109
107
  describe("clearDimension", () => {
110
108
  it("resets one dimension and leaves the rest untouched", () => {
111
- const f = base({ statuses: ["failed"], triggered_by: "cron", retries_only: true });
112
- const cleared = clearDimension(f, "triggered_by");
113
- expect(cleared.triggered_by).toBeUndefined();
109
+ const f = base({ statuses: ["failed"], triggeredBy: "cron", retriesOnly: true });
110
+ const cleared = clearDimension(f, "triggeredBy");
111
+ expect(cleared.triggeredBy).toBeUndefined();
114
112
  expect(cleared.statuses).toEqual(["failed"]);
115
- expect(cleared.retries_only).toBe(true);
113
+ expect(cleared.retriesOnly).toBe(true);
116
114
  });
117
115
 
118
116
  it("clears the exit-code expression", () => {
119
- const f = base({ exit_code: ">100 <150" });
120
- expect(clearDimension(f, "exit_code").exit_code).toBeUndefined();
117
+ const f = base({ exitCode: ">100 <150" });
118
+ expect(clearDimension(f, "exitCode").exitCode).toBeUndefined();
121
119
  });
122
120
 
123
121
  it("clears both time bounds together", () => {
124
- const f = base({ created_after: "2026-01-01T00:00:00Z", created_before: "2026-02-01" });
122
+ const f = base({ createdAfter: "2026-01-01T00:00:00Z", createdBefore: "2026-02-01" });
125
123
  const cleared = clearDimension(f, "time");
126
- expect(cleared.created_after).toBeUndefined();
127
- expect(cleared.created_before).toBeUndefined();
124
+ expect(cleared.createdAfter).toBeUndefined();
125
+ expect(cleared.createdBefore).toBeUndefined();
128
126
  });
129
127
  });
130
128
 
@@ -132,18 +130,18 @@ describe("clearPopoverFilters", () => {
132
130
  it("clears every popover dimension but keeps search and sort", () => {
133
131
  const f = base({
134
132
  search: "nightly",
135
- sort_direction: "asc",
133
+ sortDirection: "asc",
136
134
  statuses: ["failed"],
137
- task_name: "backup",
138
- triggered_by: "cron",
139
- exit_code: "1",
140
- retries_only: true,
141
- created_after: "2026-01-01T00:00:00Z",
135
+ taskName: "backup",
136
+ triggeredBy: "cron",
137
+ exitCode: "1",
138
+ retriesOnly: true,
139
+ createdAfter: "2026-01-01T00:00:00Z",
142
140
  });
143
141
  const cleared = clearPopoverFilters(f);
144
142
  expect(activeFilterCount(cleared)).toBe(0);
145
143
  expect(cleared.search).toBe("nightly");
146
- expect(cleared.sort_direction).toBe("asc");
144
+ expect(cleared.sortDirection).toBe("asc");
147
145
  });
148
146
  });
149
147