@runwisp/ui 1.1.0 → 1.3.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": "1.1.0",
4
+ "version": "1.3.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -49,8 +49,8 @@
49
49
  "devDependencies": {
50
50
  "@chromatic-com/storybook": "^5.2.1",
51
51
  "@eslint/js": "^10.0.1",
52
- "@runwisp/eslint-config": "1.1.0",
53
- "@runwisp/typescript-config": "1.1.0",
52
+ "@runwisp/eslint-config": "1.3.0",
53
+ "@runwisp/typescript-config": "1.3.0",
54
54
  "@storybook/addon-a11y": "^10.5.5",
55
55
  "@storybook/addon-docs": "^10.5.5",
56
56
  "@storybook/addon-svelte-csf": "^5.1.2",
@@ -93,7 +93,7 @@
93
93
  "@fontsource-variable/geist-mono": "^5.3.0",
94
94
  "@fontsource/tasa-orbiter": "^5.3.0",
95
95
  "@lucide/svelte": "^1.28.0",
96
- "@runwisp/common": "1.1.0",
96
+ "@runwisp/common": "1.3.0",
97
97
  "@tanstack/svelte-virtual": "^3.13.35",
98
98
  "ansi-to-html": "^0.7.2",
99
99
  "cron-parser": "^5.7.0",
@@ -0,0 +1,115 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { arrival, leave, prefersReducedMotion } from "./row-motion.js";
6
+
7
+ const LEAVE_MS = 160;
8
+ const CLOSE_GAP_DELAY_MS = 110;
9
+ const SHIFT_MS = 200;
10
+ const SHIFT_DELAY = "--rw-shift-delay";
11
+
12
+ // The helpers read the reduced-motion preference off `matchMedia` and write to
13
+ // the narrow element slices they declare, so a stubbed global and plain objects
14
+ // stand in for the browser (this repo's vitest env has no jsdom).
15
+ function setReducedMotion(reduce: boolean): void {
16
+ vi.stubGlobal("matchMedia", (media: string) => ({ media, matches: reduce }));
17
+ }
18
+
19
+ /** A list element recording the custom properties a leaving row sets on it. */
20
+ function listNode() {
21
+ const props = new Map<string, string>();
22
+ return {
23
+ props,
24
+ style: {
25
+ setProperty: (name: string, value: string | null) => {
26
+ props.set(name, value ?? "");
27
+ },
28
+ removeProperty: (name: string) => {
29
+ const previous = props.get(name) ?? "";
30
+ props.delete(name);
31
+ return previous;
32
+ },
33
+ },
34
+ };
35
+ }
36
+
37
+ describe("prefersReducedMotion", () => {
38
+ afterEach(() => vi.unstubAllGlobals());
39
+
40
+ it("reports what the reduced-motion media query matches", () => {
41
+ setReducedMotion(true);
42
+ expect(prefersReducedMotion()).toBe(true);
43
+ setReducedMotion(false);
44
+ expect(prefersReducedMotion()).toBe(false);
45
+ });
46
+ });
47
+
48
+ describe("arrival", () => {
49
+ it("plays the arrival cue on a row mounting for a fresh run", () => {
50
+ const added: string[] = [];
51
+ arrival({ classList: { add: (token: string) => added.push(token) } }, true);
52
+ expect(added).toEqual(["run-arrive"]);
53
+ });
54
+
55
+ it("leaves a row that mounted for an already-known run alone", () => {
56
+ const added: string[] = [];
57
+ arrival({ classList: { add: (token: string) => added.push(token) } }, false);
58
+ expect(added).toEqual([]);
59
+ });
60
+ });
61
+
62
+ describe("leave", () => {
63
+ beforeEach(() => {
64
+ vi.useFakeTimers();
65
+ setReducedMotion(false);
66
+ });
67
+ afterEach(() => {
68
+ vi.useRealTimers();
69
+ vi.unstubAllGlobals();
70
+ });
71
+
72
+ it("fades out a run removed live and delays the rows closing its gap", () => {
73
+ const list = listNode();
74
+ const config = leave({ dataset: { runId: "run-1" }, parentElement: list }, () => true);
75
+
76
+ expect(config.duration).toBe(LEAVE_MS);
77
+ expect(list.props.get(SHIFT_DELAY)).toBe("110ms");
78
+
79
+ // Halfway through: half faded, halfway back to its resting offset.
80
+ expect(config.css?.(0.5, 0.5)).toBe("opacity: 0.5; translate: -6px 0");
81
+
82
+ // The delay is a one-shot for this removal, not a lasting list style.
83
+ vi.advanceTimersByTime(CLOSE_GAP_DELAY_MS + SHIFT_MS);
84
+ expect(list.props.has(SHIFT_DELAY)).toBe(false);
85
+ });
86
+
87
+ it("still animates a row with no list element around it", () => {
88
+ const config = leave({ dataset: { runId: "run-1" }, parentElement: null }, () => true);
89
+ expect(config.duration).toBe(LEAVE_MS);
90
+ });
91
+
92
+ it("goes instantly when the run left for any reason other than a live removal", () => {
93
+ const list = listNode();
94
+ const scrolledOut = leave(
95
+ { dataset: { runId: "run-1" }, parentElement: list },
96
+ () => false,
97
+ );
98
+ const noTracker = leave({ dataset: { runId: "run-1" }, parentElement: list }, undefined);
99
+ const noRunId = leave({ dataset: {}, parentElement: list }, () => true);
100
+
101
+ expect(scrolledOut.duration).toBe(0);
102
+ expect(noTracker.duration).toBe(0);
103
+ expect(noRunId.duration).toBe(0);
104
+ expect(list.props.has(SHIFT_DELAY)).toBe(false);
105
+ });
106
+
107
+ it("goes instantly when the operator prefers reduced motion", () => {
108
+ setReducedMotion(true);
109
+ const list = listNode();
110
+ const config = leave({ dataset: { runId: "run-1" }, parentElement: list }, () => true);
111
+
112
+ expect(config.duration).toBe(0);
113
+ expect(list.props.has(SHIFT_DELAY)).toBe(false);
114
+ });
115
+ });
@@ -0,0 +1,75 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { cubicOut } from "svelte/easing";
5
+ import { flip, type AnimationConfig } from "svelte/animate";
6
+ import type { TransitionConfig } from "svelte/transition";
7
+
8
+ const LEAVE_MS = 160;
9
+ // Rows closing the gap wait for most of the fade, so they don't slide over a
10
+ // row that is still visible.
11
+ const CLOSE_GAP_DELAY_MS = 110;
12
+ const SHIFT_MS = 200;
13
+
14
+ // The slices of a row (and of the list it sits in) these helpers touch. Every
15
+ // real row element satisfies them; naming just the members used keeps the
16
+ // helpers unit-testable without a browser DOM.
17
+ type ArrivingRow = { readonly classList: Pick<DOMTokenList, "add"> };
18
+ type LeavingRow = {
19
+ readonly dataset: DOMStringMap;
20
+ readonly parentElement: { readonly style: ListStyle } | null;
21
+ };
22
+ type ListStyle = Pick<CSSStyleDeclaration, "setProperty" | "removeProperty">;
23
+
24
+ export function prefersReducedMotion(): boolean {
25
+ return matchMedia("(prefers-reduced-motion: reduce)").matches;
26
+ }
27
+
28
+ /**
29
+ * Plays the live-arrival cue (`.run-arrive` in theme.css) on a list row that
30
+ * mounts for a freshly arrived run. Mount-only, so the run's next re-render
31
+ * (pending → running) can't cut the animation short.
32
+ */
33
+ export function arrival(node: ArrivingRow, fresh: boolean) {
34
+ if (fresh) node.classList.add("run-arrive");
35
+ }
36
+
37
+ /**
38
+ * Outro for a run row (`out:leave={motion?.removed}`): fades and sweeps the
39
+ * row out when `removed` says its run (read from `data-run-id`) left live.
40
+ * Rows leaving for any other reason (scrolled out of the virtual window, a
41
+ * filter change) go instantly. Also sets `--rw-shift-delay` on the list so
42
+ * rows sliding up with a CSS transition start after the fade.
43
+ */
44
+ export function leave(
45
+ node: LeavingRow,
46
+ removed: ((runId: string) => boolean) | undefined,
47
+ ): TransitionConfig {
48
+ const runId = node.dataset.runId;
49
+ if (!runId || removed?.(runId) !== true || prefersReducedMotion()) return { duration: 0 };
50
+ const list = node.parentElement;
51
+ if (list) {
52
+ list.style.setProperty("--rw-shift-delay", `${String(CLOSE_GAP_DELAY_MS)}ms`);
53
+ setTimeout(
54
+ () => list.style.removeProperty("--rw-shift-delay"),
55
+ CLOSE_GAP_DELAY_MS + SHIFT_MS,
56
+ );
57
+ }
58
+ return {
59
+ duration: LEAVE_MS,
60
+ easing: cubicOut,
61
+ css: (t) => `opacity: ${String(t)}; translate: ${String((1 - t) * -12)}px 0`,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * `animate:` for plain (non-virtual) run lists: flip, except rows moving up to
67
+ * close a removed row's gap wait for its fade.
68
+ */
69
+ export function shift(node: Element, rects: { from: DOMRect; to: DOMRect }): AnimationConfig {
70
+ if (prefersReducedMotion()) return { duration: 0 };
71
+ return flip(node, rects, {
72
+ duration: SHIFT_MS,
73
+ delay: rects.to.top < rects.from.top ? CLOSE_GAP_DELAY_MS : 0,
74
+ });
75
+ }
@@ -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);
@@ -251,7 +278,8 @@
251
278
  displayed === "crashed" ||
252
279
  displayed === "log_overflow" ||
253
280
  displayed === "missed" ||
254
- displayed === "start_failed"
281
+ displayed === "start_failed" ||
282
+ displayed === "unhealthy"
255
283
  )
256
284
  return "var(--color-danger-surface)";
257
285
  if (displayed === "timeout" || displayed === "daemon_stopped" || displayed === "queue_full")
@@ -355,7 +383,10 @@
355
383
  <!-- The panel: a status spine runs the full left edge across both the header
356
384
  readout and the console below, hugging the rail divider (artifact
357
385
  ".detail .spine"). -->
358
- <div class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
386
+ <div
387
+ use:easeInFresh={run.id}
388
+ class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
389
+ >
359
390
  <!-- Status spine: a vivid edge coloring the panel by outcome; it streams
360
391
  a light sweep while a run is live. -->
361
392
  <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"
@@ -179,6 +179,7 @@ describe("STATUS_BUCKETS", () => {
179
179
  "timeout",
180
180
  "log_overflow",
181
181
  "start_failed",
182
+ "unhealthy",
182
183
  "missed",
183
184
  ];
184
185
 
@@ -266,6 +267,7 @@ describe("triggerDescription", () => {
266
267
  expect(triggerDescription("cli")).toBe("CLI");
267
268
  expect(triggerDescription("service")).toBe("Service auto-start");
268
269
  expect(triggerDescription("startup")).toBe("On daemon start");
270
+ expect(triggerDescription("hook")).toBe("Hook");
269
271
  });
270
272
 
271
273
  it("falls back to a humanized token for anything unknown", () => {
@@ -275,7 +277,15 @@ describe("triggerDescription", () => {
275
277
 
276
278
  describe("FILTERABLE_TRIGGERS", () => {
277
279
  it("offers every trigger except station", () => {
278
- expect(FILTERABLE_TRIGGERS).toEqual(["cron", "api", "ui", "cli", "service", "startup"]);
280
+ expect(FILTERABLE_TRIGGERS).toEqual([
281
+ "cron",
282
+ "api",
283
+ "ui",
284
+ "cli",
285
+ "service",
286
+ "startup",
287
+ "hook",
288
+ ]);
279
289
  expect(FILTERABLE_TRIGGERS).not.toContain("station");
280
290
  });
281
291
  });
@@ -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
  }
@@ -29,6 +29,7 @@ const RUN_VERDICTS: Record<RunStatus, RunVerdict> = {
29
29
  daemon_stopped: { verb: "cut short after", timed: true },
30
30
  log_overflow: { verb: "killed after", timed: true },
31
31
  start_failed: { verb: "gave up after", timed: true },
32
+ unhealthy: { verb: "killed as unhealthy after", timed: true },
32
33
  ended: { verb: "ended after", timed: true },
33
34
  running: { verb: "running for", timed: true },
34
35
  pending: { verb: "queued", timed: false },
@@ -90,6 +91,7 @@ export function formatTriggeredByLabel(triggeredBy: Run["triggeredBy"]): string
90
91
  if (triggeredBy === "cron") return "Cron";
91
92
  if (triggeredBy === "service") return "Service";
92
93
  if (triggeredBy === "startup") return "Startup";
94
+ if (triggeredBy === "hook") return "Hook";
93
95
  return "Station";
94
96
  }
95
97
 
@@ -13,6 +13,7 @@ import {
13
13
  FileExclamationPoint,
14
14
  CalendarX,
15
15
  OctagonX,
16
+ HeartCrack,
16
17
  } from "@lucide/svelte";
17
18
  import type { Component } from "svelte";
18
19
  import { displayStatus, type RunStatus, type Run } from "@runwisp/common";
@@ -153,6 +154,16 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
153
154
  description:
154
155
  "The run (or service instance) kept failing; after restart_attempts consecutive failures, RunWisp gave up restarting it automatically.",
155
156
  },
157
+ unhealthy: {
158
+ icon: HeartCrack,
159
+ color: "text-danger-surface",
160
+ bg: "bg-danger-soft",
161
+ border: "border-danger-soft",
162
+ dot: "bg-danger-surface",
163
+ badge: "bg-danger-soft text-danger-soft-text",
164
+ description:
165
+ "The service instance kept failing its health_check, so RunWisp stopped it. Its restart policy decides what happens next.",
166
+ },
156
167
  pending: {
157
168
  icon: Clock,
158
169
  color: "text-on-surface-muted",
package/src/lib/index.ts CHANGED
@@ -154,3 +154,5 @@ export {
154
154
  export { formatShortId } from "./utils/id.js";
155
155
  export { debounce } from "./utils/debounce.js";
156
156
  export { TickingNow } from "./utils/ticking-now.svelte.js";
157
+ export { RunMotion } from "./utils/run-motion.js";
158
+ export { arrival, leave, shift, prefersReducedMotion } from "./actions/row-motion.js";
package/src/lib/theme.css CHANGED
@@ -259,7 +259,17 @@ h3 {
259
259
  font-weight: 800;
260
260
  line-height: 1.1;
261
261
  letter-spacing: -0.02em;
262
- text-wrap: balance;
262
+ }
263
+
264
+ /* Layered, unlike the rule above: text-wrap sets the same property as
265
+ white-space, so unlayered it would beat the `truncate` utility and wrap a
266
+ heading that asked to be cut to one line. */
267
+ @layer base {
268
+ h1,
269
+ h2,
270
+ h3 {
271
+ text-wrap: balance;
272
+ }
263
273
  }
264
274
 
265
275
  /* Utility classes for the design system */
@@ -298,3 +308,42 @@ h3 {
298
308
  .btn-scale:active {
299
309
  transform: scale(0.97);
300
310
  }
311
+
312
+ /* Live arrival: the row drops in, then a primary ring glows and fades so the
313
+ eye finds the new run. Added by the `arrival` action; `translate` composes
314
+ with any inline `transform`. */
315
+ .run-arrive {
316
+ border-radius: 3px;
317
+ animation: run-arrive 1.2s ease-out;
318
+ }
319
+ @keyframes run-arrive {
320
+ 0% {
321
+ opacity: 0;
322
+ translate: 0 -6px;
323
+ box-shadow: 0 0 0 1px var(--color-primary);
324
+ }
325
+ 18% {
326
+ opacity: 1;
327
+ translate: 0 0;
328
+ box-shadow:
329
+ 0 0 0 1px var(--color-primary),
330
+ 0 0 12px color-mix(in srgb, var(--color-primary) 35%, transparent);
331
+ }
332
+ 100% {
333
+ box-shadow: 0 0 0 1px transparent;
334
+ }
335
+ }
336
+ @keyframes run-arrive-still {
337
+ 0%,
338
+ 18% {
339
+ box-shadow: 0 0 0 1px var(--color-primary);
340
+ }
341
+ 100% {
342
+ box-shadow: 0 0 0 1px transparent;
343
+ }
344
+ }
345
+ @media (prefers-reduced-motion: reduce) {
346
+ .run-arrive {
347
+ animation-name: run-arrive-still;
348
+ }
349
+ }
@@ -0,0 +1,67 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { RunMotion } from "./run-motion.js";
6
+
7
+ const WINDOW_MS = 2000;
8
+
9
+ describe("RunMotion", () => {
10
+ beforeEach(() => vi.useFakeTimers());
11
+ afterEach(() => vi.useRealTimers());
12
+
13
+ it("reports nothing for a run it was never told about", () => {
14
+ const motion = new RunMotion();
15
+ expect(motion.arrived("run-1")).toBe(false);
16
+ expect(motion.removed("run-1")).toBe(false);
17
+ });
18
+
19
+ it("keeps arrivals and removals apart", () => {
20
+ const motion = new RunMotion();
21
+ motion.markArrived("arrived-run");
22
+ motion.markRemoved("removed-run");
23
+ expect(motion.arrived("arrived-run")).toBe(true);
24
+ expect(motion.removed("arrived-run")).toBe(false);
25
+ expect(motion.removed("removed-run")).toBe(true);
26
+ expect(motion.arrived("removed-run")).toBe(false);
27
+ });
28
+
29
+ it("forgets a run once the window passes, so a later click doesn't animate it", () => {
30
+ const motion = new RunMotion();
31
+ motion.markArrived("run-1");
32
+ motion.markRemoved("run-2");
33
+ vi.advanceTimersByTime(WINDOW_MS - 1);
34
+ expect(motion.arrived("run-1")).toBe(true);
35
+ expect(motion.removed("run-2")).toBe(true);
36
+ vi.advanceTimersByTime(1);
37
+ expect(motion.arrived("run-1")).toBe(false);
38
+ expect(motion.removed("run-2")).toBe(false);
39
+ });
40
+
41
+ it("prunes expired runs when a later one is marked", () => {
42
+ const motion = new RunMotion();
43
+ motion.markArrived("old-run");
44
+ vi.advanceTimersByTime(WINDOW_MS);
45
+ motion.markArrived("new-run");
46
+ expect(motion.arrived("old-run")).toBe(false);
47
+ expect(motion.arrived("new-run")).toBe(true);
48
+ });
49
+
50
+ it("re-marking a run restarts its window", () => {
51
+ const motion = new RunMotion();
52
+ motion.markArrived("run-1");
53
+ vi.advanceTimersByTime(WINDOW_MS - 1);
54
+ motion.markArrived("run-1");
55
+ vi.advanceTimersByTime(WINDOW_MS - 1);
56
+ expect(motion.arrived("run-1")).toBe(true);
57
+ });
58
+
59
+ it("clear forgets everything", () => {
60
+ const motion = new RunMotion();
61
+ motion.markArrived("arrived-run");
62
+ motion.markRemoved("removed-run");
63
+ motion.clear();
64
+ expect(motion.arrived("arrived-run")).toBe(false);
65
+ expect(motion.removed("removed-run")).toBe(false);
66
+ });
67
+ });
@@ -0,0 +1,56 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // Long enough to span an SSE insert and the trigger's HTTP response that
5
+ // selects the new run; short enough that a later click never re-animates it.
6
+ const WINDOW_MS = 2000;
7
+
8
+ class RecentIds {
9
+ readonly #at = new Map<string, number>();
10
+
11
+ mark(id: string): void {
12
+ const now = Date.now();
13
+ for (const [known, at] of this.#at) {
14
+ if (now - at >= WINDOW_MS) this.#at.delete(known);
15
+ }
16
+ this.#at.set(id, now);
17
+ }
18
+
19
+ has(id: string): boolean {
20
+ const at = this.#at.get(id);
21
+ return at !== undefined && Date.now() - at < WINDOW_MS;
22
+ }
23
+
24
+ clear(): void {
25
+ this.#at.clear();
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Remembers which runs arrived or were removed live moments ago (a trigger, a
31
+ * scheduled firing, a run finishing, a delete), as opposed to rows that a page
32
+ * fetch, scroll or filter change loaded or dropped, so only genuine changes
33
+ * animate. Plain (non-reactive): it is only read at mount, unmount or
34
+ * selection time.
35
+ */
36
+ export class RunMotion {
37
+ readonly #arrived = new RecentIds();
38
+ readonly #removed = new RecentIds();
39
+
40
+ markArrived(runId: string): void {
41
+ this.#arrived.mark(runId);
42
+ }
43
+
44
+ markRemoved(runId: string): void {
45
+ this.#removed.mark(runId);
46
+ }
47
+
48
+ // Arrows so they can be handed around unbound.
49
+ arrived = (runId: string): boolean => this.#arrived.has(runId);
50
+ removed = (runId: string): boolean => this.#removed.has(runId);
51
+
52
+ clear(): void {
53
+ this.#arrived.clear();
54
+ this.#removed.clear();
55
+ }
56
+ }