@runwisp/ui 1.0.1 → 1.2.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 (35) hide show
  1. package/package.json +4 -4
  2. package/src/astro/SiteFooter.astro +149 -31
  3. package/src/lib/actions/row-motion.test.ts +115 -0
  4. package/src/lib/actions/row-motion.ts +75 -0
  5. package/src/lib/components/AlertDialog.svelte +63 -25
  6. package/src/lib/components/Button.svelte +1 -1
  7. package/src/lib/components/DataGrid.svelte +151 -90
  8. package/src/lib/components/FilterBar.svelte +156 -0
  9. package/src/lib/components/FilterPill.svelte +180 -0
  10. package/src/lib/components/NotificationBell.svelte +70 -0
  11. package/src/lib/components/SearchInput.svelte +91 -0
  12. package/src/lib/components/StatusDot.svelte +38 -0
  13. package/src/lib/components/ThemeToggle.svelte +28 -33
  14. package/src/lib/components/dashboard/RunDetailPanel.svelte +31 -1
  15. package/src/lib/components/dashboard/RunsList.svelte +28 -4
  16. package/src/lib/components/dashboard/run-filters.test.ts +10 -1
  17. package/src/lib/components/dashboard/run-filters.ts +4 -2
  18. package/src/lib/components/dashboard/run-helpers.ts +1 -0
  19. package/src/lib/components/filter-spec.ts +94 -0
  20. package/src/lib/index.ts +28 -1
  21. package/src/lib/theme.css +39 -0
  22. package/src/lib/utils/debounce.test.ts +31 -0
  23. package/src/lib/utils/debounce.ts +22 -0
  24. package/src/lib/utils/filter.test.ts +172 -0
  25. package/src/lib/utils/filter.ts +79 -0
  26. package/src/lib/utils/run-motion.test.ts +67 -0
  27. package/src/lib/utils/run-motion.ts +56 -0
  28. package/src/lib/utils/theme.svelte.test.ts +142 -0
  29. package/src/lib/utils/theme.svelte.ts +71 -0
  30. package/src/stories/DataGrid.stories.svelte +64 -1
  31. package/src/stories/FilterBar.stories.svelte +61 -0
  32. package/src/stories/Welcome.mdx +1 -1
  33. package/src/astro/CTABand.astro +0 -39
  34. package/src/astro/Hero.astro +0 -58
  35. package/src/astro/SiteHeader.astro +0 -46
@@ -0,0 +1,180 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <!--
5
+ One filter, one element. The pill is both the control and its own "chip":
6
+ inactive it reads `Label ▾` (muted); active it reads `Label: value ✕` (accent)
7
+ with an inline clear. Active vs inactive is a restyle of the SAME element — it
8
+ never spawns a second row, so the table below never jumps. Clicking the body
9
+ opens a type-specific editor popover (select menu / number / date range).
10
+ -->
11
+
12
+ <script lang="ts">
13
+ import { untrack } from "svelte";
14
+ import Popover from "./Popover.svelte";
15
+ import Input from "./Input.svelte";
16
+ import { Check, ChevronDown, X } from "@lucide/svelte";
17
+ import {
18
+ type FilterField,
19
+ type FilterValues,
20
+ fieldKeys,
21
+ fieldLabel,
22
+ isFieldActive,
23
+ isBlank,
24
+ } from "./filter-spec.js";
25
+
26
+ let {
27
+ field,
28
+ values = $bindable({}),
29
+ // Open the editor on mount — set when the pill was just added from the
30
+ // "+ Add filter" menu, so the user lands straight in the value picker.
31
+ autoOpen = false,
32
+ onClose,
33
+ }: {
34
+ field: FilterField;
35
+ values?: FilterValues;
36
+ autoOpen?: boolean;
37
+ onClose?: () => void;
38
+ } = $props();
39
+
40
+ // Capture the initial autoOpen once; later prop changes must not reopen it.
41
+ let open = $state(untrack(() => autoOpen));
42
+ const active = $derived(isFieldActive(field, values));
43
+ const label = $derived(fieldLabel(field));
44
+
45
+ // The active-state summary shown after the label (`Label: <this>`).
46
+ const summary = $derived.by(() => {
47
+ if (field.type === "select") {
48
+ const v = values[field.key];
49
+ return field.options.find((o) => String(o.value) === String(v))?.label ?? String(v);
50
+ }
51
+ if (field.type === "daterange") {
52
+ const from = values[`${field.key}From`];
53
+ const to = values[`${field.key}To`];
54
+ return `${from || "…"} → ${to || "…"}`;
55
+ }
56
+ return values[field.key] ?? "";
57
+ });
58
+
59
+ function clear() {
60
+ for (const k of fieldKeys(field)) values[k] = "";
61
+ }
62
+
63
+ // Tell the parent when the editor closes so an abandoned "just added" pill
64
+ // (opened but left blank) can be dropped from the visible set.
65
+ let wasOpen = false;
66
+ $effect(() => {
67
+ if (wasOpen && !open) onClose?.();
68
+ wasOpen = open;
69
+ });
70
+
71
+ // Date-range quick presets. Browser runtime, so reading the clock is fine.
72
+ const DAY_MS = 86_400_000;
73
+ const isoDay = (ms: number) => new Date(ms).toISOString().slice(0, 10);
74
+ function setRange(days: number) {
75
+ if (field.type !== "daterange") return;
76
+ const now = Date.now();
77
+ values[`${field.key}From`] = isoDay(now - days * DAY_MS);
78
+ values[`${field.key}To`] = isoDay(now);
79
+ }
80
+ </script>
81
+
82
+ <!--
83
+ Split pill: the body opens the editor (Popover trigger); the trailing ✕ is a
84
+ separate sibling button that clears the field. Keeping ✕ outside the Popover
85
+ trigger avoids nesting one interactive control inside another.
86
+ -->
87
+ <div
88
+ class="inline-flex items-center rounded-[3px] border text-sm {active
89
+ ? 'border-primary-soft-border bg-primary-soft text-primary-soft-text'
90
+ : 'border-outline bg-surface-raised text-on-surface-muted hover:border-outline-hover hover:text-on-surface'}"
91
+ >
92
+ <Popover bind:open placement="bottom-start" mobileSheet>
93
+ {#snippet trigger()}
94
+ <span class="inline-flex cursor-pointer items-center gap-1.5 px-3 py-2">
95
+ <span class="truncate">
96
+ {label}{#if active}<span class="font-medium">: {summary}</span>{/if}
97
+ </span>
98
+ {#if !active}<ChevronDown size={14} class="opacity-60" />{/if}
99
+ </span>
100
+ {/snippet}
101
+
102
+ {#if field.type === "select"}
103
+ <div class="flex w-52 flex-col gap-0.5">
104
+ {#each field.options as opt (String(opt.value))}
105
+ {@const selected = String(values[field.key] ?? "") === String(opt.value)}
106
+ <button
107
+ type="button"
108
+ class="flex items-center justify-between gap-2 rounded-[3px] px-2.5 py-1.5 text-left text-sm hover:bg-surface-sunken {selected
109
+ ? 'text-on-surface'
110
+ : 'text-on-surface-muted'}"
111
+ onclick={() => {
112
+ values[field.key] = String(opt.value);
113
+ open = false;
114
+ }}
115
+ >
116
+ <span class="truncate">{opt.label}</span>
117
+ {#if selected}<Check size={15} class="shrink-0 text-primary" />{/if}
118
+ </button>
119
+ {/each}
120
+ </div>
121
+ {:else if field.type === "number"}
122
+ <div class="w-44">
123
+ <Input
124
+ type="number"
125
+ value={values[field.key] ?? ""}
126
+ placeholder={field.placeholder ?? field.label}
127
+ autofocus
128
+ onchange={(e) => (values[field.key] = e.currentTarget.value)}
129
+ onkeydown={(e) => {
130
+ if (e.key === "Enter") open = false;
131
+ }}
132
+ />
133
+ </div>
134
+ {:else if field.type === "daterange"}
135
+ <div class="flex w-64 flex-col gap-3">
136
+ <div class="flex items-center gap-2">
137
+ <label class="flex-1">
138
+ <span class="mb-1 block text-xs text-on-surface-faint">From</span>
139
+ <Input type="date" bind:value={values[`${field.key}From`]} size="sm" />
140
+ </label>
141
+ <label class="flex-1">
142
+ <span class="mb-1 block text-xs text-on-surface-faint">To</span>
143
+ <Input type="date" bind:value={values[`${field.key}To`]} size="sm" />
144
+ </label>
145
+ </div>
146
+ <div class="flex flex-wrap gap-1.5">
147
+ {#each [{ label: "Today", days: 0 }, { label: "7 days", days: 7 }, { label: "30 days", days: 30 }] as p (p.days)}
148
+ <button
149
+ type="button"
150
+ class="rounded-[3px] border border-outline bg-surface-raised px-2 py-1 text-xs text-on-surface-muted hover:border-outline-hover hover:text-on-surface"
151
+ onclick={() => setRange(p.days)}
152
+ >
153
+ {p.label}
154
+ </button>
155
+ {/each}
156
+ {#if !isBlank(values[`${field.key}From`]) || !isBlank(values[`${field.key}To`])}
157
+ <button
158
+ type="button"
159
+ class="ml-auto rounded-[3px] px-2 py-1 text-xs text-on-surface-muted hover:text-on-surface"
160
+ onclick={() => clear()}
161
+ >
162
+ Clear
163
+ </button>
164
+ {/if}
165
+ </div>
166
+ </div>
167
+ {/if}
168
+ </Popover>
169
+
170
+ {#if active}
171
+ <button
172
+ type="button"
173
+ class="self-stretch rounded-r-[3px] border-l border-primary-soft-border px-2 hover:bg-primary/15"
174
+ onclick={clear}
175
+ aria-label="Clear {label} filter"
176
+ >
177
+ <X size={13} />
178
+ </button>
179
+ {/if}
180
+ </div>
@@ -0,0 +1,70 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <!--
5
+ Header bell + unread badge + popover shell. The app owns the store and the
6
+ list items (they're domain-specific); it passes them in as `children`.
7
+ -->
8
+
9
+ <script lang="ts">
10
+ import type { Snippet } from "svelte";
11
+ import { Bell } from "@lucide/svelte";
12
+ import Popover from "./Popover.svelte";
13
+
14
+ interface Props {
15
+ unread: number;
16
+ /** Badge colour: "danger" when an unread item is an error. */
17
+ tone?: "primary" | "danger";
18
+ open?: boolean;
19
+ /** Panel title; the right side of the header takes `actions`. */
20
+ title?: string;
21
+ actions?: Snippet;
22
+ children: Snippet;
23
+ class?: string;
24
+ }
25
+
26
+ let {
27
+ unread,
28
+ tone = "primary",
29
+ open = $bindable(false),
30
+ title = "Notifications",
31
+ actions,
32
+ children,
33
+ class: className = "",
34
+ }: Props = $props();
35
+
36
+ let badge = $derived(unread > 99 ? "99+" : String(unread));
37
+ </script>
38
+
39
+ <Popover bind:open placement="bottom-end" mobileSheet class="flex {className}">
40
+ {#snippet trigger()}
41
+ <span
42
+ class="relative flex h-9 w-9 items-center justify-center rounded-[3px] border border-transparent text-on-surface-muted hover:border-outline-hover hover:bg-surface-sunken hover:text-primary"
43
+ title="Notifications"
44
+ >
45
+ <Bell size={18} aria-hidden="true" />
46
+ <span class="sr-only">{title}</span>
47
+ {#if unread > 0}
48
+ <span
49
+ class="absolute -top-0.5 -right-0.5 inline-flex min-w-[18px] items-center justify-center rounded-full px-1 font-mono text-2xs font-bold tabular-nums {tone ===
50
+ 'danger'
51
+ ? 'bg-danger-surface text-on-danger'
52
+ : 'bg-primary text-on-primary'}"
53
+ aria-label={`${badge} unread`}
54
+ >
55
+ {badge}
56
+ </span>
57
+ {/if}
58
+ </span>
59
+ {/snippet}
60
+
61
+ <div role="dialog" aria-label={title} class="w-96 max-w-[90vw]">
62
+ <div class="mb-2 flex items-center justify-between gap-2">
63
+ <h2 class="font-mono text-sm font-semibold text-on-surface">{title}</h2>
64
+ {#if actions}
65
+ <div class="flex items-center gap-3 text-xs">{@render actions()}</div>
66
+ {/if}
67
+ </div>
68
+ {@render children()}
69
+ </div>
70
+ </Popover>
@@ -0,0 +1,91 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <!--
5
+ Debounced search box for FilterBar. `value` is the committed term the parent
6
+ filters on; keystrokes are debounced so client re-filtering / server refetch
7
+ fires once the user pauses, not per character. `Esc` and the trailing ✕ clear
8
+ immediately. Exposes its raw <input> via `element` so FilterBar's `/` shortcut
9
+ can focus it.
10
+ -->
11
+
12
+ <script lang="ts">
13
+ import Input from "./Input.svelte";
14
+ import { Search, X } from "@lucide/svelte";
15
+ import { untrack, onDestroy } from "svelte";
16
+ import { debounce } from "../utils/debounce.js";
17
+
18
+ let {
19
+ value = $bindable(""),
20
+ placeholder = "Search...",
21
+ element = $bindable<HTMLInputElement | null>(null),
22
+ }: {
23
+ value?: string | undefined;
24
+ placeholder?: string;
25
+ element?: HTMLInputElement | null;
26
+ } = $props();
27
+
28
+ // `raw` is the live text; `value` is the debounced, committed term. Seeded
29
+ // once from `value` so a URL-restored search survives mount undebounced.
30
+ let raw = $state(value);
31
+ let wrapperEl = $state<HTMLDivElement | null>(null);
32
+
33
+ const commit = debounce((v: string) => (value = v), 250);
34
+ onDestroy(() => commit.cancel());
35
+
36
+ // Expose the real input for FilterBar's `/`-to-focus shortcut.
37
+ $effect(() => {
38
+ element = wrapperEl?.querySelector("input") ?? null;
39
+ });
40
+
41
+ // External writes to `value` (Reset / clear-all) win immediately: adopt them
42
+ // and drop any pending debounce. Depends only on `value` (raw is read
43
+ // untracked), so typing never re-enters here. Tiny race: a reset that leaves
44
+ // `value` unchanged ("" -> "") can't cancel a first-keystroke debounce —
45
+ // negligible at 250ms. ponytail: guarded two-way sync, fine for one field.
46
+ $effect(() => {
47
+ const v = value;
48
+ untrack(() => {
49
+ if (v !== raw) {
50
+ commit.cancel();
51
+ raw = v;
52
+ }
53
+ });
54
+ });
55
+
56
+ function clear() {
57
+ commit.cancel();
58
+ raw = "";
59
+ value = "";
60
+ wrapperEl?.querySelector("input")?.focus();
61
+ }
62
+
63
+ function onKeydown(e: KeyboardEvent) {
64
+ if (e.key === "Escape" && raw) {
65
+ e.preventDefault();
66
+ clear();
67
+ }
68
+ }
69
+ </script>
70
+
71
+ <div bind:this={wrapperEl} class="relative w-full sm:w-64">
72
+ <Input
73
+ bind:value={raw}
74
+ {placeholder}
75
+ oninput={() => commit(raw)}
76
+ onkeydown={onKeydown}
77
+ style={raw ? "padding-right:2.25rem" : undefined}
78
+ >
79
+ {#snippet leadingIcon()}<Search size={16} />{/snippet}
80
+ </Input>
81
+ {#if raw}
82
+ <button
83
+ type="button"
84
+ class="absolute top-1/2 right-2 -translate-y-1/2 rounded-[3px] p-1 text-on-surface-faint hover:bg-surface-sunken hover:text-on-surface"
85
+ onclick={clear}
86
+ aria-label="Clear search"
87
+ >
88
+ <X size={14} />
89
+ </button>
90
+ {/if}
91
+ </div>
@@ -0,0 +1,38 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts" module>
5
+ export type StatusDotTone = "primary" | "success" | "warning" | "danger" | "muted";
6
+ </script>
7
+
8
+ <script lang="ts">
9
+ interface Props {
10
+ tone?: StatusDotTone;
11
+ /** Radar ping around the dot — use for "live" states only. */
12
+ pulse?: boolean;
13
+ size?: "sm" | "md" | "lg";
14
+ class?: string;
15
+ }
16
+
17
+ let { tone = "primary", pulse = false, size = "sm", class: className = "" }: Props = $props();
18
+
19
+ const TONES: Record<StatusDotTone, string> = {
20
+ primary: "bg-primary",
21
+ success: "bg-success-surface",
22
+ warning: "bg-warning-surface",
23
+ danger: "bg-danger-surface",
24
+ muted: "bg-on-surface-faint",
25
+ };
26
+ const SIZES = { sm: "h-2 w-2", md: "h-2.5 w-2.5", lg: "h-3.5 w-3.5" };
27
+ </script>
28
+
29
+ <span class="relative flex shrink-0 {SIZES[size]} {className}" aria-hidden="true">
30
+ {#if pulse}
31
+ <span
32
+ class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 motion-reduce:animate-none {TONES[
33
+ tone
34
+ ]}"
35
+ ></span>
36
+ {/if}
37
+ <span class="relative inline-flex h-full w-full rounded-full {TONES[tone]}"></span>
38
+ </span>
@@ -2,46 +2,41 @@
2
2
  <!-- SPDX-License-Identifier: Apache-2.0 -->
3
3
 
4
4
  <script lang="ts">
5
- import { Moon, Sun } from "@lucide/svelte";
5
+ import { ChevronDown, Monitor, Moon, Sun } from "@lucide/svelte";
6
+ import Dropdown from "./Dropdown.svelte";
7
+ import { themeStore, type ThemePreference } from "../utils/theme.svelte.js";
6
8
 
7
9
  interface Props {
8
- storageKey?: string;
9
10
  class?: string;
10
11
  }
11
12
 
12
- let { storageKey = "runwisp:theme", class: className = "" }: Props = $props();
13
+ let { class: className = "" }: Props = $props();
13
14
 
14
- let isDark = $state(false);
15
- let mounted = $state(false);
15
+ const options: { value: ThemePreference; label: string; icon: typeof Monitor }[] = [
16
+ { value: "auto", label: "Auto", icon: Monitor },
17
+ { value: "light", label: "Light", icon: Sun },
18
+ { value: "dark", label: "Dark", icon: Moon },
19
+ ];
16
20
 
17
- $effect(() => {
18
- isDark = document.documentElement.classList.contains("dark");
19
- mounted = true;
20
- });
21
+ let TriggerIcon = $derived(
22
+ themeStore.preference === "auto" ? Monitor : themeStore.resolved === "dark" ? Moon : Sun,
23
+ );
21
24
 
22
- function toggle(): void {
23
- const next = !isDark;
24
- isDark = next;
25
- document.documentElement.classList.toggle("dark", next);
26
- try {
27
- localStorage.setItem(storageKey, next ? "dark" : "light");
28
- } catch {
29
- // localStorage may be unavailable (private mode, embedded contexts) — fall through.
30
- }
31
- }
25
+ let items = $derived(
26
+ options.map((o) => ({
27
+ label: o.label,
28
+ icon: o.icon,
29
+ selected: themeStore.preference === o.value,
30
+ onClick: () => themeStore.set(o.value),
31
+ })),
32
+ );
32
33
  </script>
33
34
 
34
- <button
35
- type="button"
36
- onclick={toggle}
37
- aria-pressed={isDark}
38
- aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
39
- title={isDark ? "Switch to light theme" : "Switch to dark theme"}
40
- class="inline-flex h-9 w-9 items-center justify-center rounded-[3px] border border-outline bg-surface-raised text-on-surface-muted hover:border-outline-hover hover:text-on-surface {className}"
41
- >
42
- {#if mounted && isDark}
43
- <Sun size={16} />
44
- {:else}
45
- <Moon size={16} />
46
- {/if}
47
- </button>
35
+ <Dropdown {items} align="right" triggerLabel="Theme" class={className}>
36
+ {#snippet trigger()}
37
+ <span class="flex items-center gap-1">
38
+ <TriggerIcon size={18} />
39
+ <ChevronDown size={14} class="opacity-60" />
40
+ </span>
41
+ {/snippet}
42
+ </Dropdown>
@@ -29,6 +29,8 @@
29
29
  import Popover from "../Popover.svelte";
30
30
  import Tooltip from "../Tooltip.svelte";
31
31
  import { portal } from "../../actions/portal.js";
32
+ import { prefersReducedMotion } from "../../actions/row-motion.js";
33
+ import type { RunMotion } from "../../utils/run-motion.js";
32
34
  import type { Run } from "./types.js";
33
35
  import { isLogEvent, type LogEvent, type LogSlice } from "../../log-console/types.js";
34
36
  import { formatClockTime, formatCalendarDate } from "../../utils/format.js";
@@ -63,6 +65,7 @@
63
65
  historyVisible = false,
64
66
  highlightLine = null,
65
67
  getInstanceCount = () => 1,
68
+ motion,
66
69
  notFound = false,
67
70
  }: {
68
71
  run: Run | undefined;
@@ -109,12 +112,36 @@
109
112
  // Resolves a task's currently configured instance count so multi-instance
110
113
  // services render a 1-based #N suffix. Defaults to single-instance.
111
114
  getInstanceCount?: (taskName: string) => number;
115
+ // Which runs arrived or were removed live moments ago. The panel eases in
116
+ // when a new run takes it (triggered, or a scheduled run auto-selected)
117
+ // or when its run was deleted and another takes its place; picking a
118
+ // run by hand swaps instantly.
119
+ motion?: RunMotion;
112
120
  // True when a deep-linked run id resolved to no run (deleted by retention,
113
121
  // or never existed). The empty state then says so plainly instead of the
114
122
  // generic "Select a run" — the caller must not silently substitute another.
115
123
  notFound?: boolean;
116
124
  } = $props();
117
125
 
126
+ function easeInFresh(node: HTMLElement, runId: string) {
127
+ let shown = runId;
128
+ const play = (id: string) => {
129
+ const previous = shown;
130
+ shown = id;
131
+ if (!motion?.arrived(id) && !motion?.removed(previous)) return;
132
+ if (prefersReducedMotion()) return;
133
+ node.animate(
134
+ [
135
+ { opacity: 0.35, translate: "0 6px" },
136
+ { opacity: 1, translate: "0 0" },
137
+ ],
138
+ { duration: 200, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)" },
139
+ );
140
+ };
141
+ play(runId);
142
+ return { update: play };
143
+ }
144
+
118
145
  let canDelete = $derived.by(() => {
119
146
  if (!run || !onDelete) return false;
120
147
  const status = runDisplayStatus(run);
@@ -355,7 +382,10 @@
355
382
  <!-- The panel: a status spine runs the full left edge across both the header
356
383
  readout and the console below, hugging the rail divider (artifact
357
384
  ".detail .spine"). -->
358
- <div class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
385
+ <div
386
+ use:easeInFresh={run.id}
387
+ class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
388
+ >
359
389
  <!-- Status spine: a vivid edge coloring the panel by outcome; it streams
360
390
  a light sweep while a run is live. -->
361
391
  <div
@@ -15,6 +15,8 @@
15
15
  import { SvelteSet } from "svelte/reactivity";
16
16
  import { createVirtualizer } from "@tanstack/svelte-virtual";
17
17
  import Button from "../Button.svelte";
18
+ import { arrival, leave, prefersReducedMotion } from "../../actions/row-motion.js";
19
+ import type { RunMotion } from "../../utils/run-motion.js";
18
20
  import EmptyState from "../EmptyState.svelte";
19
21
  import RunFilterPopover from "./RunFilterPopover.svelte";
20
22
  import type { Run } from "./types.js";
@@ -68,6 +70,7 @@
68
70
  onBulkDelete,
69
71
  onBulkRerun,
70
72
  getInstanceCount = () => 1,
73
+ motion,
71
74
  flush = false,
72
75
  outputSearch = false,
73
76
  outputQuery = "",
@@ -99,6 +102,9 @@
99
102
  // Resolves a task's currently configured instance count so multi-instance
100
103
  // services render a 1-based #N suffix. Defaults to single-instance.
101
104
  getInstanceCount?: (taskName: string) => number;
105
+ // Which runs arrived or were removed live moments ago: their rows animate
106
+ // in or out. Page loads, scrolling and filtering never do.
107
+ motion?: RunMotion;
102
108
  // Flush rail mode (task detail page): render as a borderless rail that
103
109
  // fills its column and is divided from the detail panel by a single right
104
110
  // border — no card chrome of its own. Default renders the standalone card
@@ -189,8 +195,13 @@
189
195
 
190
196
  // `setOptions` always notifies the store (see @tanstack/svelte-virtual src),
191
197
  // so reading $virtualizer here would loop. Untrack the store read.
192
- $effect(() => {
198
+ // Pre-effect so the count lands in the same render as `items`: otherwise
199
+ // the row pushed past the old count unmounts for a frame and remounts
200
+ // without its slide. A pre-effect first runs before `bind:this`, so it also
201
+ // re-runs once the scroll element exists or the virtualizer never attaches.
202
+ $effect.pre(() => {
193
203
  const count = items.length;
204
+ if (!scrollElement) return;
194
205
  untrack(() => $virtualizer.setOptions({ count }));
195
206
  });
196
207
 
@@ -222,8 +233,10 @@
222
233
  if (!scrollElement) return;
223
234
  lastScrolled = id;
224
235
  // "auto" only scrolls when the row is off-screen, so visible selections
225
- // (and manual clicks) don't jump.
226
- untrack(() => $virtualizer.scrollToIndex(index, { align: "auto" }));
236
+ // (and manual clicks) don't jump. A live arrival taking focus glides
237
+ // there so the operator sees where it went; deep links jump.
238
+ const behavior = motion?.arrived(id) && !prefersReducedMotion() ? "smooth" : "auto";
239
+ untrack(() => $virtualizer.scrollToIndex(index, { align: "auto", behavior }));
227
240
  });
228
241
 
229
242
  function isRowSelected(id: string): boolean {
@@ -575,8 +588,19 @@
575
588
  {#each $virtualizer.getVirtualItems() as row (items[row.index]?.id ?? row.index)}
576
589
  {@const run = items[row.index]}
577
590
  {#if run}
591
+ <!-- The transform transition slides rows aside when a live
592
+ run is inserted or removed. Navigation never animates:
593
+ filter/sort/task changes remount rows, pagination
594
+ appends, and scrolling doesn't move a row. `leave`
595
+ is global because this {#if}, not the row's each
596
+ item, is the block it would otherwise wait for; it
597
+ only plays for runs removed live. -->
578
598
  <div
579
- class="group/row flex items-center gap-1"
599
+ data-run-id={run.id}
600
+ use:arrival={motion?.arrived(run.id) ?? false}
601
+ out:leave|global={motion?.removed}
602
+ class="group/row flex items-center gap-1 transition-transform duration-200 ease-out motion-reduce:transition-none"
603
+ style:transition-delay="var(--rw-shift-delay, 0ms)"
580
604
  style:position="absolute"
581
605
  style:top="0"
582
606
  style:left="0"
@@ -266,6 +266,7 @@ describe("triggerDescription", () => {
266
266
  expect(triggerDescription("cli")).toBe("CLI");
267
267
  expect(triggerDescription("service")).toBe("Service auto-start");
268
268
  expect(triggerDescription("startup")).toBe("On daemon start");
269
+ expect(triggerDescription("hook")).toBe("Hook");
269
270
  });
270
271
 
271
272
  it("falls back to a humanized token for anything unknown", () => {
@@ -275,7 +276,15 @@ describe("triggerDescription", () => {
275
276
 
276
277
  describe("FILTERABLE_TRIGGERS", () => {
277
278
  it("offers every trigger except station", () => {
278
- expect(FILTERABLE_TRIGGERS).toEqual(["cron", "api", "ui", "cli", "service", "startup"]);
279
+ expect(FILTERABLE_TRIGGERS).toEqual([
280
+ "cron",
281
+ "api",
282
+ "ui",
283
+ "cli",
284
+ "service",
285
+ "startup",
286
+ "hook",
287
+ ]);
279
288
  expect(FILTERABLE_TRIGGERS).not.toContain("station");
280
289
  });
281
290
  });
@@ -227,8 +227,8 @@ export function humanizeStatus(status: string): string {
227
227
  * Descriptive label for a run's trigger source — fuller than the one-word row
228
228
  * badge (`formatTriggeredByLabel`) for the "cron"/"station"/"service"/"startup"
229
229
  * sources, where the extra words disambiguate (e.g. "REST API" vs a bare
230
- * "API"). "ui" and "cli" already read fine as their bare badge word, so those
231
- * two match `formatTriggeredByLabel` exactly.
230
+ * "API"). "ui", "cli" and "hook" already read fine as their bare badge word,
231
+ * so those three match `formatTriggeredByLabel` exactly.
232
232
  *
233
233
  * Note `cron` covers both on-time schedule firings and catch-up for missed
234
234
  * runs, and `startup` is specifically `run_on_start` (not catch-up).
@@ -249,6 +249,8 @@ export function triggerDescription(trigger: string): string {
249
249
  return "Service auto-start";
250
250
  case "startup":
251
251
  return "On daemon start";
252
+ case "hook":
253
+ return "Hook";
252
254
  default:
253
255
  return humanizeStatus(trigger);
254
256
  }
@@ -90,6 +90,7 @@ export function formatTriggeredByLabel(triggeredBy: Run["triggeredBy"]): string
90
90
  if (triggeredBy === "cron") return "Cron";
91
91
  if (triggeredBy === "service") return "Service";
92
92
  if (triggeredBy === "startup") return "Startup";
93
+ if (triggeredBy === "hook") return "Hook";
93
94
  return "Station";
94
95
  }
95
96