@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.
- package/package.json +4 -4
- package/src/astro/SiteFooter.astro +149 -31
- package/src/lib/actions/row-motion.test.ts +115 -0
- package/src/lib/actions/row-motion.ts +75 -0
- package/src/lib/components/AlertDialog.svelte +63 -25
- package/src/lib/components/Button.svelte +1 -1
- package/src/lib/components/DataGrid.svelte +151 -90
- package/src/lib/components/FilterBar.svelte +156 -0
- package/src/lib/components/FilterPill.svelte +180 -0
- package/src/lib/components/NotificationBell.svelte +70 -0
- package/src/lib/components/SearchInput.svelte +91 -0
- package/src/lib/components/StatusDot.svelte +38 -0
- package/src/lib/components/ThemeToggle.svelte +28 -33
- package/src/lib/components/dashboard/RunDetailPanel.svelte +31 -1
- package/src/lib/components/dashboard/RunsList.svelte +28 -4
- package/src/lib/components/dashboard/run-filters.test.ts +10 -1
- package/src/lib/components/dashboard/run-filters.ts +4 -2
- package/src/lib/components/dashboard/run-helpers.ts +1 -0
- package/src/lib/components/filter-spec.ts +94 -0
- package/src/lib/index.ts +28 -1
- package/src/lib/theme.css +39 -0
- package/src/lib/utils/debounce.test.ts +31 -0
- package/src/lib/utils/debounce.ts +22 -0
- package/src/lib/utils/filter.test.ts +172 -0
- package/src/lib/utils/filter.ts +79 -0
- package/src/lib/utils/run-motion.test.ts +67 -0
- package/src/lib/utils/run-motion.ts +56 -0
- package/src/lib/utils/theme.svelte.test.ts +142 -0
- package/src/lib/utils/theme.svelte.ts +71 -0
- package/src/stories/DataGrid.stories.svelte +64 -1
- package/src/stories/FilterBar.stories.svelte +61 -0
- package/src/stories/Welcome.mdx +1 -1
- package/src/astro/CTABand.astro +0 -39
- package/src/astro/Hero.astro +0 -58
- 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 {
|
|
13
|
+
let { class: className = "" }: Props = $props();
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
$
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
});
|
|
21
|
+
let TriggerIcon = $derived(
|
|
22
|
+
themeStore.preference === "auto" ? Monitor : themeStore.resolved === "dark" ? Moon : Sun,
|
|
23
|
+
);
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
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
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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([
|
|
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 "
|
|
231
|
-
*
|
|
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
|
|