@runwisp/ui 1.4.0 → 1.5.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 (70) hide show
  1. package/package.json +4 -5
  2. package/src/lib/actions/floating.ts +73 -0
  3. package/src/lib/actions/portal.ts +5 -1
  4. package/src/lib/components/Accordion.svelte +6 -2
  5. package/src/lib/components/AccordionItem.svelte +80 -23
  6. package/src/lib/components/Avatar.svelte +0 -1
  7. package/src/lib/components/CodeBlock.svelte +84 -35
  8. package/src/lib/components/CronInput.svelte +5 -2
  9. package/src/lib/components/DataGrid.svelte +95 -133
  10. package/src/lib/components/Drawer.svelte +2 -6
  11. package/src/lib/components/Dropdown.svelte +19 -57
  12. package/src/lib/components/EmptyState.svelte +2 -1
  13. package/src/lib/components/ErrorState.svelte +2 -1
  14. package/src/lib/components/Heading.svelte +19 -12
  15. package/src/lib/components/Kbd.svelte +25 -10
  16. package/src/lib/components/Link.svelte +7 -1
  17. package/src/lib/components/LogConsole.svelte +30 -32
  18. package/src/lib/components/Modal.svelte +44 -33
  19. package/src/lib/components/NotificationBell.svelte +2 -1
  20. package/src/lib/components/PageContainer.svelte +1 -10
  21. package/src/lib/components/PageHeader.svelte +8 -4
  22. package/src/lib/components/Popover.svelte +9 -51
  23. package/src/lib/components/Progress.svelte +19 -6
  24. package/src/lib/components/Radio.svelte +16 -4
  25. package/src/lib/components/RadioGroup.svelte +15 -2
  26. package/src/lib/components/Select.svelte +13 -45
  27. package/src/lib/components/Table.svelte +13 -4
  28. package/src/lib/components/TableBody.svelte +2 -1
  29. package/src/lib/components/TableCell.svelte +38 -8
  30. package/src/lib/components/TableHead.svelte +12 -4
  31. package/src/lib/components/TableRow.svelte +23 -3
  32. package/src/lib/components/TaskCard.svelte +1 -1
  33. package/src/lib/components/TimezoneSelect.svelte +2 -2
  34. package/src/lib/components/accordion.svelte.ts +19 -0
  35. package/src/lib/components/dashboard/RunDetailPanel.svelte +91 -63
  36. package/src/lib/components/dashboard/RunFilterPopover.svelte +7 -16
  37. package/src/lib/components/dashboard/RunsList.svelte +9 -18
  38. package/src/lib/components/dashboard/cron-format.ts +1 -1
  39. package/src/lib/components/dashboard/run-filters.test.ts +2 -58
  40. package/src/lib/components/dashboard/run-filters.ts +5 -53
  41. package/src/lib/components/dashboard/run-helpers.test.ts +17 -7
  42. package/src/lib/components/dashboard/run-helpers.ts +12 -1
  43. package/src/lib/components/dashboard/status-config.ts +38 -83
  44. package/src/lib/components/dashboard/types.ts +0 -26
  45. package/src/lib/components/radio-group.ts +11 -0
  46. package/src/lib/index.ts +8 -43
  47. package/src/lib/log-console/LogFetcher.svelte.ts +3 -14
  48. package/src/lib/log-console/highlight-scroll.test.ts +59 -0
  49. package/src/lib/log-console/highlight-scroll.ts +41 -0
  50. package/src/lib/theme.css +10 -4
  51. package/src/lib/utils/clipboard.svelte.test.ts +92 -0
  52. package/src/lib/utils/clipboard.svelte.ts +73 -0
  53. package/src/lib/utils/format.ts +65 -94
  54. package/src/lib/utils/id.test.ts +3 -7
  55. package/src/lib/utils/id.ts +3 -2
  56. package/src/lib/utils/theme.svelte.test.ts +0 -6
  57. package/src/lib/utils/theme.svelte.ts +3 -5
  58. package/src/lib/utils/toast.svelte.ts +3 -11
  59. package/src/stories/Accordion.stories.svelte +61 -0
  60. package/src/stories/CodeBlock.stories.svelte +55 -0
  61. package/src/stories/DataGrid.stories.svelte +0 -5
  62. package/src/stories/Heading.stories.svelte +31 -0
  63. package/src/stories/Kbd.stories.svelte +36 -0
  64. package/src/stories/Link.stories.svelte +38 -0
  65. package/src/stories/PageHeader.stories.svelte +31 -0
  66. package/src/stories/Progress.stories.svelte +26 -0
  67. package/src/stories/RadioGroup.stories.svelte +43 -0
  68. package/src/stories/Table.stories.svelte +69 -0
  69. package/src/svelte-shims.d.ts +0 -5
  70. package/src/lib/components/dashboard/status-config.test.ts +0 -18
@@ -0,0 +1,41 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { untrack } from "svelte";
5
+
6
+ export interface HighlightScrollOptions {
7
+ /** Reactive getter for the line to reveal; null/undefined means none. */
8
+ line: () => number | null | undefined;
9
+ /** Reactive getter: true once the layout can place `line` (container measured, line exists). */
10
+ ready: (line: number) => boolean;
11
+ /** Scrolls to `line`. Runs untracked, so layout reads here never re-trigger the sync. */
12
+ reveal: (line: number) => void;
13
+ /** Called when the highlight is cleared. */
14
+ clear: () => void;
15
+ }
16
+
17
+ /**
18
+ * Returns a function to call from inside an `$effect`. It reveals each
19
+ * highlighted line exactly once: after the first reveal, layout changes (new
20
+ * log lines, resizes) no longer re-run the scroll, so the operator can scroll
21
+ * away from a deep-linked search hit and stay there. It waits until `ready`,
22
+ * and a changed or re-set `line` reveals again.
23
+ */
24
+ export function createHighlightScroll(options: HighlightScrollOptions): () => void {
25
+ let revealed: number | null = null;
26
+ return () => {
27
+ const target = options.line();
28
+ // Not `!target`: line 0 is a valid highlight.
29
+ if (typeof target !== "number") {
30
+ revealed = null;
31
+ options.clear();
32
+ return;
33
+ }
34
+ // Checked before `ready` so a revealed line leaves no layout dependency behind.
35
+ if (revealed === target || !options.ready(target)) return;
36
+ revealed = target;
37
+ untrack(() => {
38
+ options.reveal(target);
39
+ });
40
+ };
41
+ }
package/src/lib/theme.css CHANGED
@@ -19,6 +19,10 @@
19
19
  @plugin "@tailwindcss/typography";
20
20
 
21
21
  @theme {
22
+ /* Wide enough for the sidebar, run list and run detail to stay side by
23
+ * side for good; below it the operator can fold the first two away. */
24
+ --breakpoint-3xl: 100rem;
25
+
22
26
  /* Palette aliases — Tailwind utilities (bg-wisp-500, text-mist-900, …)
23
27
  * resolve through to --rw-color-* declared in theme-tokens.css. */
24
28
  --color-wisp-50: var(--rw-color-wisp-50);
@@ -251,10 +255,12 @@ body {
251
255
  color: var(--rw-on-surface);
252
256
  }
253
257
 
254
- /* Headings are mono chrome — the runbook voice. Components should not fight this. */
255
- h1,
256
- h2,
257
- h3 {
258
+ /* Headings are mono chrome — the runbook voice. Unlayered, so it beats the
259
+ weight/leading/tracking utilities on a raw <h1>-<h3>: every heading reads the
260
+ same. The Heading component opts out (data-rw-heading) and carries this look
261
+ in its own classes, so its props are the source of truth. :is()/:where()
262
+ keep the selector at element specificity, so app stylesheets still win. */
263
+ :is(h1, h2, h3):where(:not([data-rw-heading])) {
258
264
  font-family: var(--font-mono);
259
265
  font-weight: 800;
260
266
  line-height: 1.1;
@@ -0,0 +1,92 @@
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 { CopyFeedback, copyText } from "./clipboard.svelte.js";
6
+
7
+ describe("copyText", () => {
8
+ afterEach(() => {
9
+ vi.unstubAllGlobals();
10
+ });
11
+
12
+ it("writes through navigator.clipboard", async () => {
13
+ const writeText = vi.fn(() => Promise.resolve());
14
+ vi.stubGlobal("navigator", { clipboard: { writeText } });
15
+ expect(await copyText("runwisp")).toBe(true);
16
+ expect(writeText).toHaveBeenCalledWith("runwisp");
17
+ });
18
+
19
+ it("falls back to execCommand when the clipboard API is missing", async () => {
20
+ vi.stubGlobal("navigator", {});
21
+ const textarea = {
22
+ value: "",
23
+ readOnly: false,
24
+ style: {},
25
+ select: vi.fn(),
26
+ remove: vi.fn(),
27
+ };
28
+ const execCommand = vi.fn(() => true);
29
+ class FakeElement {
30
+ focus = vi.fn();
31
+ }
32
+ const focused = new FakeElement();
33
+ vi.stubGlobal("HTMLElement", FakeElement);
34
+ vi.stubGlobal("document", {
35
+ activeElement: focused,
36
+ createElement: () => textarea,
37
+ body: { appendChild: vi.fn() },
38
+ execCommand,
39
+ });
40
+ expect(await copyText("runwisp")).toBe(true);
41
+ expect(textarea.value).toBe("runwisp");
42
+ expect(execCommand).toHaveBeenCalledWith("copy");
43
+ expect(textarea.remove).toHaveBeenCalled();
44
+ expect(focused.focus).toHaveBeenCalled();
45
+ });
46
+
47
+ it("reports failure when no copy path exists", async () => {
48
+ vi.stubGlobal("navigator", {});
49
+ vi.stubGlobal("document", undefined);
50
+ expect(await copyText("runwisp")).toBe(false);
51
+ });
52
+ });
53
+
54
+ describe("CopyFeedback", () => {
55
+ beforeEach(() => {
56
+ vi.useFakeTimers();
57
+ vi.stubGlobal("navigator", { clipboard: { writeText: () => Promise.resolve() } });
58
+ });
59
+ afterEach(() => {
60
+ vi.useRealTimers();
61
+ vi.unstubAllGlobals();
62
+ });
63
+
64
+ it("flags the copied key until the reset delay passes", async () => {
65
+ const feedback = new CopyFeedback(1000);
66
+ await feedback.copy("docker run", "docker");
67
+ expect(feedback.copied).toBe(true);
68
+ expect(feedback.isCopied("docker")).toBe(true);
69
+ expect(feedback.isCopied("binary")).toBe(false);
70
+ vi.advanceTimersByTime(1000);
71
+ expect(feedback.copied).toBe(false);
72
+ });
73
+
74
+ it("restarts the reset delay on a second copy", async () => {
75
+ const feedback = new CopyFeedback(1000);
76
+ await feedback.copy("a");
77
+ vi.advanceTimersByTime(800);
78
+ await feedback.copy("b");
79
+ vi.advanceTimersByTime(800);
80
+ expect(feedback.copied).toBe(true);
81
+ });
82
+
83
+ it("stays idle when the copy fails", async () => {
84
+ vi.stubGlobal("navigator", {
85
+ clipboard: { writeText: () => Promise.reject(new Error("denied")) },
86
+ });
87
+ vi.stubGlobal("document", undefined);
88
+ const feedback = new CopyFeedback();
89
+ expect(await feedback.copy("a")).toBe(false);
90
+ expect(feedback.copied).toBe(false);
91
+ });
92
+ });
@@ -0,0 +1,73 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ const DEFAULT_RESET_MS = 2000;
5
+
6
+ // Copies text to the clipboard and reports whether it worked. navigator.clipboard
7
+ // only exists in secure contexts, so a daemon served over plain http on a LAN IP
8
+ // falls back to the legacy textarea + execCommand path.
9
+ export async function copyText(text: string): Promise<boolean> {
10
+ if (typeof navigator !== "undefined" && "clipboard" in navigator) {
11
+ try {
12
+ await navigator.clipboard.writeText(text);
13
+ return true;
14
+ } catch {
15
+ // Denied or blocked: try the legacy path below.
16
+ }
17
+ }
18
+ return legacyCopy(text);
19
+ }
20
+
21
+ function legacyCopy(text: string): boolean {
22
+ if (typeof document === "undefined") return false;
23
+ const previousFocus = document.activeElement;
24
+ const ta = document.createElement("textarea");
25
+ ta.value = text;
26
+ ta.readOnly = true;
27
+ ta.style.position = "fixed";
28
+ ta.style.opacity = "0";
29
+ document.body.appendChild(ta);
30
+ ta.select();
31
+ let ok = false;
32
+ try {
33
+ // Deprecated, but still the only copy path outside a secure context.
34
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
35
+ ok = document.execCommand("copy"); // NOSONAR
36
+ } catch {
37
+ ok = false;
38
+ }
39
+ ta.remove();
40
+ if (previousFocus instanceof HTMLElement) previousFocus.focus();
41
+ return ok;
42
+ }
43
+
44
+ // CopyFeedback drives a copy button's transient "copied" state. `key` tells
45
+ // several buttons sharing one instance apart (one per tab or snippet).
46
+ export class CopyFeedback {
47
+ #key = $state<string | null>(null);
48
+ #timer: ReturnType<typeof setTimeout> | undefined;
49
+ readonly #resetMs: number;
50
+
51
+ constructor(resetMs: number = DEFAULT_RESET_MS) {
52
+ this.#resetMs = resetMs;
53
+ }
54
+
55
+ get copied(): boolean {
56
+ return this.#key !== null;
57
+ }
58
+
59
+ isCopied(key: string): boolean {
60
+ return this.#key === key;
61
+ }
62
+
63
+ async copy(text: string, key: string = ""): Promise<boolean> {
64
+ const ok = await copyText(text);
65
+ if (!ok) return false;
66
+ clearTimeout(this.#timer);
67
+ this.#key = key;
68
+ this.#timer = setTimeout(() => {
69
+ this.#key = null;
70
+ }, this.#resetMs);
71
+ return true;
72
+ }
73
+ }
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  // Signed magnitude thresholds for Intl.RelativeTimeFormat, largest-first
5
- // division. Mirrors the buckets date-fns' formatDistance used to pick.
5
+ // division. The buckets match date-fns' formatDistance.
6
6
  const RELATIVE_DIVISIONS: [amount: number, unit: Intl.RelativeTimeFormatUnit][] = [
7
7
  [60, "seconds"],
8
8
  [60, "minutes"],
@@ -13,105 +13,88 @@ const RELATIVE_DIVISIONS: [amount: number, unit: Intl.RelativeTimeFormatUnit][]
13
13
  [Number.POSITIVE_INFINITY, "years"],
14
14
  ];
15
15
 
16
- // formatDistance renders a signed date delta as "N units ago" / "in N units"
17
- // using the platform Intl formatter. Negative delta (date before base) reads as
18
- // past, positive as future.
19
- function formatDistance(date: Date, base: Date): string {
20
- const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "always" });
21
- let delta = (date.getTime() - base.getTime()) / 1000;
16
+ const relativeTime = new Intl.RelativeTimeFormat(undefined, { numeric: "always" });
17
+
18
+ /** "N units ago" / "in N units" for `date` relative to `now`. */
19
+ export function formatRelativeTime(date: string | Date, now: Date = new Date()): string {
20
+ let delta = (new Date(date).getTime() - now.getTime()) / 1000;
22
21
  for (const [amount, unit] of RELATIVE_DIVISIONS) {
23
22
  if (Math.abs(delta) < amount) {
24
- return rtf.format(Math.round(delta), unit);
23
+ return relativeTime.format(Math.round(delta), unit);
25
24
  }
26
25
  delta /= amount;
27
26
  }
28
- return rtf.format(Math.round(delta), "years");
27
+ return relativeTime.format(Math.round(delta), "years");
29
28
  }
30
29
 
31
- export function formatBytes(bytes: number): string {
32
- const units: [string, ...string[]] = ["B", "KB", "MB", "GB", "TB", "PB"];
33
- let value = bytes;
34
- let unitIndex = 0;
35
- while (value >= 1024 && unitIndex < units.length - 1) {
36
- value /= 1024;
37
- unitIndex++;
38
- }
39
- const unit = units[unitIndex] ?? "B";
40
- const digits = value < 10 && unitIndex > 0 ? 1 : 0;
41
- return `${new Intl.NumberFormat("en", { maximumFractionDigits: digits }).format(value)} ${unit}`;
30
+ /** A locale date/time formatter for one fixed option set, built once. */
31
+ function dateFormatter(options: Intl.DateTimeFormatOptions): (date: string | Date) => string {
32
+ const format = new Intl.DateTimeFormat(undefined, options);
33
+ return (date) => format.format(new Date(date));
42
34
  }
43
35
 
44
- export function formatRelativeTime(dateStr: string | Date, now: Date = new Date()): string {
45
- return formatDistance(new Date(dateStr), now);
46
- }
36
+ export const formatDateTime = dateFormatter({
37
+ year: "numeric",
38
+ month: "short",
39
+ day: "numeric",
40
+ hour: "numeric",
41
+ minute: "2-digit",
42
+ });
47
43
 
48
- export function formatRelativeTimeWithAbsolute(
49
- dateStr: string | Date,
50
- now: Date = new Date(),
51
- ): string {
52
- const date = new Date(dateStr);
53
- const relative = formatDistance(date, now);
54
- const diffMs = Math.abs(now.getTime() - date.getTime());
55
- const diffDays = diffMs / (1000 * 60 * 60 * 24);
44
+ /** Wall-clock time of day, seconds included, 24-hour — e.g. "17:15:02". */
45
+ export const formatClockTime = dateFormatter({
46
+ hour: "2-digit",
47
+ minute: "2-digit",
48
+ second: "2-digit",
49
+ hour12: false,
50
+ });
56
51
 
57
- let absolute: string;
58
- if (diffDays < 1) {
59
- absolute = date.toLocaleTimeString(undefined, {
60
- hour: "numeric",
61
- minute: "2-digit",
62
- hour12: false,
63
- });
64
- } else {
65
- absolute = date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
66
- }
52
+ /** Calendar date without the time — e.g. "22 Jun 2026". */
53
+ export const formatCalendarDate = dateFormatter({
54
+ year: "numeric",
55
+ month: "short",
56
+ day: "numeric",
57
+ });
67
58
 
68
- return `${relative} (${absolute})`;
69
- }
59
+ /** Time of day, no seconds, 24-hour — e.g. "17:15". */
60
+ export const formatTimeHM = dateFormatter({ hour: "2-digit", minute: "2-digit", hour12: false });
70
61
 
71
- export function formatDateTime(dateStr: string): string {
72
- return new Date(dateStr).toLocaleString(undefined, {
73
- year: "numeric",
74
- month: "short",
75
- day: "numeric",
76
- hour: "numeric",
77
- minute: "2-digit",
78
- });
79
- }
62
+ /** Day and month, no year — e.g. "22 Jun". */
63
+ export const formatDayMonth = dateFormatter({ month: "short", day: "numeric" });
80
64
 
81
- /** Wall-clock time of day, seconds included, 24-hour — e.g. "17:15:02". */
82
- export function formatClockTime(dateStr: string): string {
83
- return new Date(dateStr).toLocaleTimeString(undefined, {
84
- hour: "2-digit",
85
- minute: "2-digit",
86
- second: "2-digit",
87
- hour12: false,
88
- });
89
- }
65
+ export const formatFullDateTime = dateFormatter({
66
+ year: "numeric",
67
+ month: "short",
68
+ day: "2-digit",
69
+ hour: "2-digit",
70
+ minute: "2-digit",
71
+ second: "2-digit",
72
+ hour12: false,
73
+ });
90
74
 
91
- /** Calendar date without the time — e.g. "22 Jun 2026". */
92
- export function formatCalendarDate(dateStr: string): string {
93
- return new Date(dateStr).toLocaleDateString(undefined, {
94
- year: "numeric",
95
- month: "short",
96
- day: "numeric",
97
- });
98
- }
75
+ const formatShortTime = dateFormatter({ hour: "numeric", minute: "2-digit", hour12: false });
99
76
 
100
- /** Time of day, no seconds, 24-hour — e.g. "17:15". */
101
- export function formatTimeHM(dateStr: string): string {
102
- return new Date(dateStr).toLocaleTimeString(undefined, {
103
- hour: "2-digit",
104
- minute: "2-digit",
105
- hour12: false,
106
- });
77
+ export function formatRelativeTimeWithAbsolute(
78
+ dateStr: string | Date,
79
+ now: Date = new Date(),
80
+ ): string {
81
+ const date = new Date(dateStr);
82
+ const diffDays = Math.abs(now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24);
83
+ const absolute = diffDays < 1 ? formatShortTime(date) : formatDayMonth(date);
84
+ return `${formatRelativeTime(date, now)} (${absolute})`;
107
85
  }
108
86
 
109
- /** Day and month, no year — e.g. "22 Jun". */
110
- export function formatDayMonth(dateStr: string): string {
111
- return new Date(dateStr).toLocaleDateString(undefined, {
112
- month: "short",
113
- day: "numeric",
114
- });
87
+ export function formatBytes(bytes: number): string {
88
+ const units: [string, ...string[]] = ["B", "KB", "MB", "GB", "TB", "PB"];
89
+ let value = bytes;
90
+ let unitIndex = 0;
91
+ while (value >= 1024 && unitIndex < units.length - 1) {
92
+ value /= 1024;
93
+ unitIndex++;
94
+ }
95
+ const unit = units[unitIndex] ?? "B";
96
+ const digits = value < 10 && unitIndex > 0 ? 1 : 0;
97
+ return `${new Intl.NumberFormat("en", { maximumFractionDigits: digits }).format(value)} ${unit}`;
115
98
  }
116
99
 
117
100
  export function formatDuration(ms: number): string {
@@ -125,15 +108,3 @@ export function formatDuration(ms: number): string {
125
108
  const remM = m % 60;
126
109
  return remM > 0 ? String(h) + "h " + String(remM) + "m" : String(h) + "h";
127
110
  }
128
-
129
- export function formatFullDateTime(date: Date | string): string {
130
- return new Intl.DateTimeFormat(undefined, {
131
- year: "numeric",
132
- month: "short",
133
- day: "2-digit",
134
- hour: "2-digit",
135
- minute: "2-digit",
136
- second: "2-digit",
137
- hour12: false,
138
- }).format(new Date(date));
139
- }
@@ -5,16 +5,12 @@ import { describe, expect, it } from "vitest";
5
5
  import { formatShortId } from "./id.js";
6
6
 
7
7
  describe("formatShortId", () => {
8
- it("returns the last 8 characters by default", () => {
8
+ it("returns the last 8 characters", () => {
9
9
  expect(formatShortId("01HVZ4XYZ123456ABCDEF")).toBe("56ABCDEF");
10
10
  });
11
11
 
12
- it("honours a custom length", () => {
13
- expect(formatShortId("01HVZ4XYZ123456ABCDEF", 4)).toBe("CDEF");
14
- });
15
-
16
- it("returns the whole string when shorter than the requested length", () => {
17
- expect(formatShortId("abc", 8)).toBe("abc");
12
+ it("returns the whole string when shorter than 8", () => {
13
+ expect(formatShortId("abc")).toBe("abc");
18
14
  });
19
15
 
20
16
  it("returns an empty string for an empty input", () => {
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- export function formatShortId(id: string, length = 8): string {
5
- return id.slice(-length);
4
+ /** The last 8 characters of a ULID, the part that differs between runs. */
5
+ export function formatShortId(id: string): string {
6
+ return id.slice(-8);
6
7
  }
@@ -89,12 +89,6 @@ describe("themeStore", () => {
89
89
  expect(themeStore.resolved).toBe("dark");
90
90
  });
91
91
 
92
- it("falls back to the legacy storage key", async () => {
93
- stubBrowser({ storage: { "runwisp-theme": "light" } });
94
- const { themeStore } = await import("./theme.svelte.js");
95
- expect(themeStore.preference).toBe("light");
96
- });
97
-
98
92
  it("ignores an invalid stored value", async () => {
99
93
  stubBrowser({ storage: { "runwisp:theme": "purple" } });
100
94
  const { themeStore } = await import("./theme.svelte.js");
@@ -2,13 +2,11 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  export type ThemePreference = "auto" | "light" | "dark";
5
- export type ResolvedTheme = "light" | "dark";
5
+ type ResolvedTheme = "light" | "dark";
6
6
 
7
7
  // Shared by every RunWisp surface. Keep byte-for-byte in sync with the
8
8
  // no-flash <head> scripts in each app's app.html / layout.
9
- export const THEME_STORAGE_KEY = "runwisp:theme";
10
- // The daemon dashboard used this key before the store moved here.
11
- const LEGACY_STORAGE_KEY = "runwisp-theme";
9
+ const THEME_STORAGE_KEY = "runwisp:theme";
12
10
 
13
11
  function isPreference(value: string | null): value is ThemePreference {
14
12
  return value === "auto" || value === "light" || value === "dark";
@@ -16,7 +14,7 @@ function isPreference(value: string | null): value is ThemePreference {
16
14
 
17
15
  function readStored(): string | null {
18
16
  try {
19
- return localStorage.getItem(THEME_STORAGE_KEY) ?? localStorage.getItem(LEGACY_STORAGE_KEY);
17
+ return localStorage.getItem(THEME_STORAGE_KEY);
20
18
  } catch {
21
19
  return null; // private mode / embedded contexts
22
20
  }
@@ -3,9 +3,9 @@
3
3
 
4
4
  import { generateUlid } from "@runwisp/common";
5
5
 
6
- export type ToastType = "success" | "error" | "warning" | "info";
6
+ type ToastType = "success" | "error" | "warning" | "info";
7
7
 
8
- export interface ToastAction {
8
+ interface ToastAction {
9
9
  label: string;
10
10
  onClick: () => void;
11
11
  }
@@ -18,7 +18,7 @@ export interface Toast {
18
18
  action?: ToastAction;
19
19
  }
20
20
 
21
- export interface ToastOptions {
21
+ interface ToastOptions {
22
22
  duration?: number;
23
23
  action?: ToastAction;
24
24
  }
@@ -88,14 +88,6 @@ class ToastStore {
88
88
  info(message: string, opts?: ToastOptions): string {
89
89
  return this.add("info", message, opts);
90
90
  }
91
-
92
- clear() {
93
- for (const timer of this.timers.values()) {
94
- clearTimeout(timer);
95
- }
96
- this.timers.clear();
97
- this.items = [];
98
- }
99
91
  }
100
92
 
101
93
  export const toast = new ToastStore();
@@ -0,0 +1,61 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script module>
5
+ import { defineMeta } from "@storybook/addon-svelte-csf";
6
+ import { LifeBuoy, Trash } from "@lucide/svelte";
7
+ import Accordion from "$lib/components/Accordion.svelte";
8
+ import AccordionItem from "$lib/components/AccordionItem.svelte";
9
+ import Toggle from "$lib/components/Toggle.svelte";
10
+
11
+ const { Story } = defineMeta({
12
+ title: "Navigation/Accordion",
13
+ component: Accordion,
14
+ tags: ["autodocs"],
15
+ });
16
+ </script>
17
+
18
+ <Story name="Multiple" asChild>
19
+ <Accordion class="max-w-lg">
20
+ <AccordionItem title="Retries" open>Retry a failed run up to three times.</AccordionItem>
21
+ <AccordionItem title="Timeouts">Kill the run after 10 minutes.</AccordionItem>
22
+ <AccordionItem title="Notifications">Send failures to Slack.</AccordionItem>
23
+ </Accordion>
24
+ </Story>
25
+
26
+ <Story name="Single" asChild>
27
+ <Accordion type="single" class="max-w-lg">
28
+ <AccordionItem title="Retries" open>Opening another section closes this one.</AccordionItem>
29
+ <AccordionItem title="Timeouts">Kill the run after 10 minutes.</AccordionItem>
30
+ <AccordionItem title="Notifications">Send failures to Slack.</AccordionItem>
31
+ </Accordion>
32
+ </Story>
33
+
34
+ <Story name="Framed, default open" asChild>
35
+ <AccordionItem variant="framed" open class="max-w-lg">
36
+ {#snippet title()}
37
+ <span class="flex items-center gap-2.5">
38
+ <LifeBuoy size={16} class="text-on-surface-faint" />
39
+ Schedule recovery
40
+ <span class="font-sans text-xs text-on-surface-faint">Missed ticks & jitter</span>
41
+ </span>
42
+ {/snippet}
43
+ What to do with cron ticks missed while the scheduler was offline.
44
+ </AccordionItem>
45
+ </Story>
46
+
47
+ <Story name="Framed with actions" asChild>
48
+ <AccordionItem variant="framed" title="Node.js 22" class="max-w-lg">
49
+ {#snippet actions()}
50
+ <Toggle checked size="sm" />
51
+ <button
52
+ type="button"
53
+ aria-label="Remove"
54
+ class="text-on-surface-faint hover:text-danger-surface"
55
+ >
56
+ <Trash size={14} />
57
+ </button>
58
+ {/snippet}
59
+ <pre class="font-mono text-xs">RUN apk add --no-cache nodejs npm</pre>
60
+ </AccordionItem>
61
+ </Story>
@@ -0,0 +1,55 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script module>
5
+ import { defineMeta } from "@storybook/addon-svelte-csf";
6
+ import CodeBlock from "$lib/components/CodeBlock.svelte";
7
+
8
+ const helloTask = '[tasks.hello]\ncron = "*/5 * * * *"\nrun = "echo hello"';
9
+ const backupTask = '[tasks.backup]\ncron = "0 3 * * *"\nkeep_runs = 60';
10
+
11
+ const { Story } = defineMeta({
12
+ title: "Data Display/CodeBlock",
13
+ component: CodeBlock,
14
+ tags: ["autodocs"],
15
+ argTypes: {
16
+ variant: { control: "select", options: ["terminal", "surface"] },
17
+ size: { control: "select", options: ["sm", "md"] },
18
+ },
19
+ });
20
+ </script>
21
+
22
+ <Story
23
+ name="Default"
24
+ args={{
25
+ code: "export RUNWISP_STATION_TOKEN=rw_xxx\nexport RUNWISP_STATION_URL=https://app.runwisp.com\nrunwisp station",
26
+ }}
27
+ />
28
+
29
+ <Story
30
+ name="Filename"
31
+ args={{ code: "FROM alpine:3.20\nRUN apk add --no-cache curl", filename: "Dockerfile" }}
32
+ />
33
+
34
+ <Story
35
+ name="Shell prompt"
36
+ args={{ code: "curl -fsSL https://get.runwisp.com/install.sh | bash", prompt: "$" }}
37
+ />
38
+
39
+ <Story name="Surface variant" asChild>
40
+ <CodeBlock variant="surface" size="sm" copyable={false} code={helloTask} />
41
+ </Story>
42
+
43
+ <!-- Rich children render the markup; `code` keeps the copied text plain. -->
44
+ <Story name="Highlighted children" asChild>
45
+ <!-- No whitespace between the line spans: inside <pre> it would render as blank lines. -->
46
+ <CodeBlock filename="runwisp.toml" code={backupTask}
47
+ ><span class="block text-[var(--rw-syn-fn)]">[tasks.backup]</span><span class="block"
48
+ ><span class="text-[var(--rw-syn-kw)]">cron</span> =
49
+ <span class="text-[var(--rw-syn-str)]">"0 3 * * *"</span></span
50
+ ><span class="block"
51
+ ><span class="text-[var(--rw-syn-kw)]">keep_runs</span> =
52
+ <span class="text-[var(--rw-syn-num)]">60</span></span
53
+ ></CodeBlock
54
+ >
55
+ </Story>