@runwisp/ui 0.4.0 → 0.7.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@runwisp/ui",
3
3
  "description": "Svelte 5 UI component library for RunWisp dashboards and admin interfaces.",
4
- "version": "0.4.0",
4
+ "version": "0.7.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -18,20 +18,11 @@
18
18
  "scripts": {
19
19
  "dev": "storybook dev -p 6006 --no-open",
20
20
  "build": "echo '@runwisp/ui: no build step (raw source)'",
21
- "check": "svelte-check --tsconfig ./tsconfig.json && prettier --check . && eslint .",
21
+ "check": "bun run check:types && bun run check:lint",
22
+ "check:types": "svelte-check --tsconfig ./tsconfig.json",
23
+ "check:lint": "prettier --check . && eslint .",
22
24
  "format": "prettier --write ."
23
25
  },
24
- "nx": {
25
- "targets": {
26
- "build": {
27
- "cache": true,
28
- "inputs": [
29
- "production"
30
- ],
31
- "outputs": []
32
- }
33
- }
34
- },
35
26
  "files": [
36
27
  "src"
37
28
  ],
@@ -67,8 +58,8 @@
67
58
  "@chromatic-com/storybook": "^4.1.3",
68
59
  "@eslint/compat": "^1.4.0",
69
60
  "@eslint/js": "^9.39.1",
70
- "@runwisp/eslint-config": "0.4.0",
71
- "@runwisp/typescript-config": "0.4.0",
61
+ "@runwisp/eslint-config": "0.7.0",
62
+ "@runwisp/typescript-config": "0.7.0",
72
63
  "@storybook/addon-a11y": "^10.1.9",
73
64
  "@storybook/addon-docs": "^10.1.9",
74
65
  "@storybook/addon-svelte-csf": "^5.0.10",
@@ -116,7 +107,8 @@
116
107
  "@floating-ui/dom": "^1.7.4",
117
108
  "@fontsource/tasa-orbiter": "^5.2.2",
118
109
  "@lucide/svelte": "^0.561.0",
119
- "@runwisp/common": "0.4.0",
110
+ "@runwisp/common": "0.7.0",
111
+ "@tanstack/svelte-virtual": "^3.13.25",
120
112
  "ansi-to-html": "^0.7.2",
121
113
  "cron-parser": "^5.4.0",
122
114
  "cronstrue": "^3.9.0",
package/src/app.html CHANGED
@@ -4,6 +4,7 @@
4
4
  <meta charset="utf-8" />
5
5
  <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <title>RunWisp</title>
7
8
  %sveltekit.head%
8
9
  </head>
9
10
  <body data-sveltekit-preload-data="hover">
@@ -8,9 +8,7 @@ export function portal(node: HTMLElement) {
8
8
  document.body.appendChild(node);
9
9
  return {
10
10
  destroy() {
11
- if (node.parentNode) {
12
- node.parentNode.removeChild(node);
13
- }
11
+ node.remove();
14
12
  },
15
13
  };
16
14
  }
@@ -26,8 +26,6 @@
26
26
  open = !open;
27
27
  }
28
28
  }
29
-
30
- const isSnippet = $derived(typeof title === "function");
31
29
  </script>
32
30
 
33
31
  <div class="group {className}">
@@ -44,8 +42,8 @@
44
42
  aria-expanded={open}
45
43
  >
46
44
  <span class="flex-1">
47
- {#if isSnippet && title}
48
- {@render (title as Snippet)()}
45
+ {#if typeof title === "function"}
46
+ {@render title()}
49
47
  {:else if title}
50
48
  {title}
51
49
  {/if}
@@ -89,21 +89,17 @@
89
89
  md: "h-3.5 w-3.5",
90
90
  lg: "h-4 w-4",
91
91
  };
92
-
93
- let safeVariant = $derived(
94
- (Object.hasOwn(variantClasses, variant) ? variant : "default") as BadgeVariant,
95
- );
96
92
  </script>
97
93
 
98
94
  <span
99
95
  class="{baseClasses} {outline
100
- ? variantClasses[safeVariant].outline
101
- : variantClasses[safeVariant].solid} {sizeClasses[size]} {className}"
96
+ ? variantClasses[variant].outline
97
+ : variantClasses[variant].solid} {sizeClasses[size]} {className}"
102
98
  >
103
99
  {#if dot}
104
100
  {#if loading}
105
101
  <svg
106
- class="{spinnerSizeClasses[size]} animate-spin {indicatorTextColors[safeVariant]}"
102
+ class="{spinnerSizeClasses[size]} animate-spin {indicatorTextColors[variant]}"
107
103
  viewBox="0 0 24 24"
108
104
  fill="none"
109
105
  >
@@ -122,7 +118,7 @@
122
118
  ></path>
123
119
  </svg>
124
120
  {:else}
125
- <span class="h-1.5 w-1.5 rounded-full {dotColors[safeVariant]}"></span>
121
+ <span class="h-1.5 w-1.5 rounded-full {dotColors[variant]}"></span>
126
122
  {/if}
127
123
  {/if}
128
124
  {#if children}
@@ -8,11 +8,12 @@
8
8
  padding?: "none" | "sm" | "md" | "lg";
9
9
  hover?: boolean;
10
10
  border?: boolean;
11
- shadow?: "none" | "sm" | "md" | "lg";
11
+ shadow?: "none" | "sm" | "md" | "lg" | "xl";
12
12
  header?: Snippet;
13
13
  footer?: Snippet;
14
14
  children?: Snippet;
15
15
  class?: string;
16
+ bodyClass?: string;
16
17
  }
17
18
 
18
19
  let {
@@ -24,6 +25,7 @@
24
25
  footer,
25
26
  children,
26
27
  class: className = "",
28
+ bodyClass = "",
27
29
  }: Props = $props();
28
30
 
29
31
  const paddingClasses: Record<string, string> = {
@@ -38,6 +40,7 @@
38
40
  sm: "shadow-sm",
39
41
  md: "shadow-md",
40
42
  lg: "shadow-lg",
43
+ xl: "shadow-xl shadow-mist-900/10",
41
44
  };
42
45
  </script>
43
46
 
@@ -56,7 +59,7 @@
56
59
  </div>
57
60
  {/if}
58
61
 
59
- <div class={paddingClasses[padding]}>
62
+ <div class="{paddingClasses[padding]} {bodyClass}">
60
63
  {#if children}
61
64
  {@render children()}
62
65
  {/if}
@@ -176,7 +176,7 @@
176
176
  <Input
177
177
  value={filterQuery}
178
178
  oninput={(e) => {
179
- filterQuery = (e.currentTarget as HTMLInputElement).value;
179
+ filterQuery = e.currentTarget.value;
180
180
  if (paginate && !total) page = 1;
181
181
  }}
182
182
  type="search"
@@ -308,8 +308,7 @@
308
308
  <td
309
309
  class="px-4 {compact ? 'py-2' : 'py-3'} cursor-pointer"
310
310
  onclick={(e) => {
311
- if ((e.target as HTMLElement).tagName !== "INPUT")
312
- toggleRow(row);
311
+ if (!(e.target instanceof HTMLInputElement)) toggleRow(row);
313
312
  }}
314
313
  >
315
314
  <Checkbox
@@ -90,9 +90,9 @@
90
90
  }
91
91
 
92
92
  function handleOutsideClick(e: MouseEvent) {
93
- const target = e.target as HTMLElement;
94
- if (triggerEl?.contains(target)) return;
95
- if (menuEl?.contains(target)) return;
93
+ const path = e.composedPath();
94
+ if (triggerEl && path.includes(triggerEl)) return;
95
+ if (menuEl && path.includes(menuEl)) return;
96
96
  if (open) toggle(false);
97
97
  }
98
98
 
@@ -85,8 +85,8 @@
85
85
  updateFromValue();
86
86
  });
87
87
 
88
- function handleInput(e: Event) {
89
- const val = (e.target as HTMLInputElement).value;
88
+ function handleInput(e: Event & { currentTarget: EventTarget & HTMLInputElement }) {
89
+ const val = e.currentTarget.value;
90
90
  textValue = val;
91
91
 
92
92
  const msValue = parseToMs(val);
@@ -22,13 +22,12 @@
22
22
  3: "2xl",
23
23
  4: "xl",
24
24
  5: "lg",
25
- 6: "base" as HeadingSize,
25
+ 6: "md",
26
26
  };
27
27
 
28
28
  const sizeClasses: Record<string, string> = {
29
29
  xs: "text-xs font-semibold",
30
30
  sm: "text-sm font-semibold",
31
- base: "text-base font-semibold",
32
31
  md: "text-base font-semibold",
33
32
  lg: "text-lg font-semibold",
34
33
  xl: "text-xl font-bold",
@@ -21,9 +21,23 @@
21
21
  chunkSize?: number;
22
22
  lineHeight?: number;
23
23
  class?: string;
24
+ // highlightLine, when set, scrolls the console so the given absolute
25
+ // line number is visible and pulses a short-lived flash on that row.
26
+ // The pulse is one-shot: changing the prop to a new line restarts the
27
+ // animation; setting it to null clears immediately.
28
+ highlightLine?: number | null;
24
29
  }
25
30
 
26
- let { fetchLogs, chunkSize = 4096, lineHeight = 20, class: className = "" }: Props = $props();
31
+ let {
32
+ fetchLogs,
33
+ chunkSize = 4096,
34
+ lineHeight = 20,
35
+ class: className = "",
36
+ highlightLine = null,
37
+ }: Props = $props();
38
+
39
+ let flashLine = $state<number | null>(null);
40
+ let flashTimer: ReturnType<typeof setTimeout> | null = null;
27
41
 
28
42
  const cache = new LogCache();
29
43
  let fetcher: LogFetcher | null = $state(null);
@@ -111,10 +125,11 @@
111
125
 
112
126
  // Request missing data for the visible range (read-only w.r.t. cache).
113
127
  $effect(() => {
114
- if (fetcher && cache.totalLines > 0) {
115
- fetcher.pruneQueue(visibleStart, visibleEnd);
128
+ const f = fetcher;
129
+ if (f && cache.totalLines > 0) {
130
+ f.pruneQueue(visibleStart, visibleEnd);
116
131
  untrack(() => {
117
- fetcher!.maybeRequestMissing(visibleStart, visibleEnd);
132
+ f.maybeRequestMissing(visibleStart, visibleEnd);
118
133
  });
119
134
  }
120
135
  });
@@ -136,8 +151,8 @@
136
151
  prevTotalLines = currentTotal;
137
152
  });
138
153
 
139
- function onScroll(e: Event) {
140
- const target = e.target as HTMLDivElement;
154
+ function onScroll(e: Event & { currentTarget: EventTarget & HTMLDivElement }) {
155
+ const target = e.currentTarget;
141
156
  scrollTop = target.scrollTop;
142
157
 
143
158
  const scrollHeight = target.scrollHeight;
@@ -201,6 +216,30 @@
201
216
  scrollTop = 0;
202
217
  }
203
218
 
219
+ // Search-hit deep-link: when highlightLine flips to a non-null number,
220
+ // scroll the viewport so that line is centred, and pulse a one-shot
221
+ // flash class for ~1.5s. Cleaning up the timer on prop change keeps a
222
+ // rapid sequence of jumps from leaking timers.
223
+ $effect(() => {
224
+ const target = highlightLine;
225
+ if (target === null || target === undefined) {
226
+ flashLine = null;
227
+ return;
228
+ }
229
+ if (!containerEl) return;
230
+ const targetY = lineTop(target) - containerHeight / 2 + lineHeight / 2;
231
+ const clamped = Math.max(0, Math.min(targetY, totalHeight - containerHeight));
232
+ containerEl.scrollTo({ top: clamped, behavior: "smooth" });
233
+ isAutoScroll = false;
234
+ userScrolledUp = true;
235
+ flashLine = target;
236
+ if (flashTimer !== null) clearTimeout(flashTimer);
237
+ flashTimer = setTimeout(() => {
238
+ flashLine = null;
239
+ flashTimer = null;
240
+ }, 1500);
241
+ });
242
+
204
243
  $effect(() => {
205
244
  if (!containerEl) return;
206
245
  containerHeight = containerEl.clientHeight;
@@ -241,7 +280,10 @@
241
280
 
242
281
  {#each renderedLines as line (line.num)}
243
282
  <div
244
- class="log-line absolute right-0 left-0 flex items-center hover:bg-slate-900/50"
283
+ class="log-line absolute right-0 left-0 flex items-center hover:bg-slate-900/50 {flashLine ===
284
+ line.num
285
+ ? 'log-line--flash'
286
+ : ''}"
245
287
  style="top: {lineTop(line.num)}px; height: {lineHeight}px;"
246
288
  >
247
289
  <div
@@ -375,6 +417,19 @@
375
417
  --log-line-height: 20px;
376
418
  }
377
419
 
420
+ .log-line--flash {
421
+ animation: log-line-flash 1.5s ease-out;
422
+ }
423
+
424
+ @keyframes log-line-flash {
425
+ 0% {
426
+ background-color: rgba(251, 191, 36, 0.45);
427
+ }
428
+ 100% {
429
+ background-color: transparent;
430
+ }
431
+ }
432
+
378
433
  .dot {
379
434
  display: inline-block;
380
435
  width: 4px;
@@ -0,0 +1,29 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import type { Snippet } from "svelte";
6
+
7
+ interface Props {
8
+ title: string;
9
+ subtitle?: string;
10
+ actions?: Snippet;
11
+ class?: string;
12
+ }
13
+
14
+ let { title, subtitle, actions, class: className = "" }: Props = $props();
15
+ </script>
16
+
17
+ <div class="flex shrink-0 flex-wrap items-start justify-between gap-3 px-1 {className}">
18
+ <div class="min-w-0">
19
+ <h1 class="text-2xl font-bold tracking-tight text-on-surface">{title}</h1>
20
+ {#if subtitle}
21
+ <p class="mt-0.5 text-sm text-on-surface-muted">{subtitle}</p>
22
+ {/if}
23
+ </div>
24
+ {#if actions}
25
+ <div class="flex shrink-0 items-center gap-2">
26
+ {@render actions()}
27
+ </div>
28
+ {/if}
29
+ </div>
@@ -36,8 +36,8 @@
36
36
  onPageChange?.(clamped);
37
37
  }
38
38
 
39
- function handlePageSizeChange(e: Event) {
40
- const newSize = Number((e.target as HTMLSelectElement).value);
39
+ function handlePageSizeChange(e: Event & { currentTarget: EventTarget & HTMLSelectElement }) {
40
+ const newSize = Number(e.currentTarget.value);
41
41
  onPageSizeChange?.(newSize);
42
42
  }
43
43
  </script>
@@ -64,9 +64,9 @@
64
64
  }
65
65
 
66
66
  function handleOutsideClick(e: MouseEvent) {
67
- const target = e.target as HTMLElement;
68
- if (triggerEl?.contains(target)) return;
69
- if (contentEl?.contains(target)) return;
67
+ const path = e.composedPath();
68
+ if (triggerEl && path.includes(triggerEl)) return;
69
+ if (contentEl && path.includes(contentEl)) return;
70
70
  if (open) {
71
71
  open = false;
72
72
  cleanupFloating?.();
@@ -212,16 +212,12 @@
212
212
  }
213
213
 
214
214
  function handleOutsideClick(e: MouseEvent) {
215
- if (
216
- isOpen &&
217
- triggerEl &&
218
- !triggerEl.contains(e.target as Node) &&
219
- menuEl &&
220
- !menuEl.contains(e.target as Node)
221
- ) {
222
- isOpen = false;
223
- cleanupFloating?.();
224
- }
215
+ if (!isOpen) return;
216
+ const path = e.composedPath();
217
+ if (triggerEl && path.includes(triggerEl)) return;
218
+ if (menuEl && path.includes(menuEl)) return;
219
+ isOpen = false;
220
+ cleanupFloating?.();
225
221
  }
226
222
 
227
223
  onDestroy(() => {
@@ -0,0 +1,33 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import type { Snippet } from "svelte";
6
+
7
+ export type TaskCardAccent = "danger" | "wisp" | "aurora";
8
+
9
+ interface Props {
10
+ accent?: TaskCardAccent;
11
+ onclick?: () => void;
12
+ children: Snippet;
13
+ class?: string;
14
+ }
15
+
16
+ let { accent = "wisp", onclick, children, class: className = "" }: Props = $props();
17
+
18
+ const accentClasses: Record<TaskCardAccent, string> = {
19
+ danger: "hover:border-danger-200",
20
+ wisp: "hover:border-wisp-200",
21
+ aurora: "hover:border-aurora-200",
22
+ };
23
+ </script>
24
+
25
+ <button
26
+ type="button"
27
+ class="group w-full rounded-lg border border-mist-100 bg-surface-raised p-3 text-left transition-all hover:shadow-sm {accentClasses[
28
+ accent
29
+ ]} {className}"
30
+ {onclick}
31
+ >
32
+ {@render children()}
33
+ </button>
@@ -72,6 +72,18 @@
72
72
  aria-hidden="true"
73
73
  />
74
74
  <p class="flex-1 text-sm font-medium">{toastItem.message}</p>
75
+ {#if toastItem.action}
76
+ {@const action = toastItem.action}
77
+ <button
78
+ onclick={() => {
79
+ action.onClick();
80
+ toast.remove(toastItem.id);
81
+ }}
82
+ class="flex-shrink-0 rounded-md px-2 py-1 text-xs font-semibold text-on-surface underline-offset-2 transition-colors hover:bg-surface-sunken hover:underline"
83
+ >
84
+ {action.label}
85
+ </button>
86
+ {/if}
75
87
  <button
76
88
  onclick={() => toast.remove(toastItem.id)}
77
89
  class="flex-shrink-0 rounded-lg p-1 text-on-surface-muted transition-colors hover:bg-surface-sunken hover:text-on-surface"
@@ -3,8 +3,17 @@
3
3
 
4
4
  <script lang="ts">
5
5
  import { untrack } from "svelte";
6
- import { Server, Hash, Terminal as TerminalIcon, Download } from "@lucide/svelte";
6
+ import {
7
+ Server,
8
+ Hash,
9
+ Terminal as TerminalIcon,
10
+ Download,
11
+ Trash2,
12
+ MousePointerClick,
13
+ } from "@lucide/svelte";
7
14
  import Badge from "../Badge.svelte";
15
+ import Button from "../Button.svelte";
16
+ import EmptyState from "../EmptyState.svelte";
8
17
  import LogConsole from "../LogConsole.svelte";
9
18
  import type { Run } from "./types.js";
10
19
  import type { LogEvent, LogSlice } from "../../log-console/types.js";
@@ -18,6 +27,8 @@
18
27
  fetchLogs,
19
28
  streamLogs,
20
29
  showTaskName = false,
30
+ onDelete,
31
+ highlightLine = null,
21
32
  }: {
22
33
  run: Run | undefined;
23
34
  fetchLogs: (
@@ -31,8 +42,16 @@
31
42
  initialState?: { fromLine: number },
32
43
  ) => () => void;
33
44
  showTaskName?: boolean;
45
+ onDelete?: (runId: string) => void;
46
+ highlightLine?: number | null;
34
47
  } = $props();
35
48
 
49
+ let canDelete = $derived.by(() => {
50
+ if (!run || !onDelete) return false;
51
+ const status = runDisplayStatus(run);
52
+ return status !== "running" && status !== "pending";
53
+ });
54
+
36
55
  const TAIL_LINES = 1000;
37
56
 
38
57
  let logConsole = $state<{ onStream: (event: LogEvent) => void } | null>(null);
@@ -109,6 +128,18 @@
109
128
  >
110
129
  {runDisplayStatus(run).toUpperCase()}
111
130
  </Badge>
131
+ {#if canDelete}
132
+ <Button
133
+ variant="ghost"
134
+ size="xs"
135
+ class="ml-1 text-danger-surface hover:bg-danger-soft"
136
+ onclick={() => onDelete?.(run.id)}
137
+ title="Delete this run"
138
+ aria-label="Delete run"
139
+ >
140
+ {#snippet icon()}<Trash2 size={14} />{/snippet}
141
+ </Button>
142
+ {/if}
112
143
  </div>
113
144
  <div
114
145
  class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-on-surface-muted"
@@ -236,17 +267,16 @@
236
267
  bind:this={logConsole}
237
268
  fetchLogs={(f: number, t: number) => fetchLogs(run.id, f, t)}
238
269
  class="min-h-0 flex-1"
270
+ {highlightLine}
239
271
  />
240
272
  {/key}
241
273
  </div>
242
274
  {:else}
243
- <div
244
- class="flex flex-1 flex-col items-center justify-center bg-surface-sunken/30 text-on-surface-faint"
245
- >
246
- <div class="mb-4 rounded-full border border-outline bg-surface-raised p-4 shadow-sm">
247
- <Server size={32} class="text-outline-faint" />
248
- </div>
249
- <h3 class="mb-1 font-medium text-on-surface">No Run Selected</h3>
250
- <p class="text-sm">Select a run from the history on the left to view details.</p>
275
+ <div class="flex flex-1 items-center justify-center bg-surface-sunken/30">
276
+ <EmptyState
277
+ title="Select a run"
278
+ description="Pick a run from the list to view details and logs."
279
+ icon={MousePointerClick}
280
+ />
251
281
  </div>
252
282
  {/if}