@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
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.0.1",
4
+ "version": "1.2.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.0.1",
53
- "@runwisp/typescript-config": "1.0.1",
52
+ "@runwisp/eslint-config": "1.2.0",
53
+ "@runwisp/typescript-config": "1.2.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.0.1",
96
+ "@runwisp/common": "1.2.0",
97
97
  "@tanstack/svelte-virtual": "^3.13.35",
98
98
  "ansi-to-html": "^0.7.2",
99
99
  "cron-parser": "^5.7.0",
@@ -2,46 +2,164 @@
2
2
  // SPDX-FileCopyrightText: PoppyCake, s.r.o.
3
3
  // SPDX-License-Identifier: Apache-2.0
4
4
 
5
+ // Brand footer shared by runwisp.com and docs.runwisp.com. Plain scoped CSS on
6
+ // --rw-* tokens (no Tailwind) so it renders the same under Starlight, which
7
+ // only loads theme-tokens.css.
8
+ import logoSvg from "../lib/assets/runwisp-logo.svg?raw";
9
+
10
+ export interface FooterLink {
11
+ label: string;
12
+ href: string;
13
+ external?: boolean;
14
+ }
15
+
16
+ export interface FooterColumn {
17
+ title: string;
18
+ links: FooterLink[];
19
+ }
20
+
5
21
  interface Props {
6
- tagline?: string;
22
+ columns: FooterColumn[];
23
+ homeHref?: string;
24
+ wordmark?: string;
7
25
  class?: string;
8
26
  }
9
27
 
10
- const { tagline = "Built by operators, for operators.", class: className = "" } =
11
- Astro.props;
12
-
13
- const hasTeaser = Astro.slots.has("teaser");
14
- const hasColumns = Astro.slots.has("columns");
15
- const hasBadges = Astro.slots.has("badges");
28
+ const { columns, homeHref = "/", wordmark = "RunWisp", class: className = "" } = Astro.props;
16
29
  ---
17
30
 
18
- <footer class:list={["bg-surface-sunken border-t border-outline", className]}>
19
- <div class="mx-auto max-w-6xl px-6 py-12">
20
- {
21
- hasTeaser && (
22
- <div class="bg-surface-raised mb-10 rounded-[4px] border border-outline p-6 shadow-sm">
23
- <slot name="teaser" />
24
- </div>
25
- )
26
- }
27
- {
28
- hasColumns && (
29
- <div class="grid gap-8 font-mono sm:grid-cols-2 lg:grid-cols-4 [&_a]:block [&_a]:text-on-surface-muted [&_a]:[&_a:hover]:text-primary [&_h4]:mb-3 [&_h4]:text-xs [&_h4]:font-medium [&_h4]:uppercase [&_h4]:tracking-[0.16em] [&_h4]:text-on-surface-faint [&_h4]:before:mr-1.5 [&_h4]:before:content-['──']">
30
- <slot name="columns" />
31
- </div>
32
- )
33
- }
34
- <div
35
- class="text-on-surface-muted mt-10 flex flex-col items-start justify-between gap-4 border-t border-outline-faint pt-6 font-mono text-xs sm:flex-row sm:items-center"
36
- >
37
- <p>{tagline}</p>
31
+ <footer class:list={["rw-ft", className]}>
32
+ <div class="rw-ft-wrap">
33
+ <div class="rw-ft-grid" style={`--rw-ft-cols: ${columns.length}`}>
34
+ <div class="rw-ft-brand">
35
+ <a class="rw-ft-mark" href={homeHref}
36
+ ><span class="rw-ft-logo" aria-hidden="true" set:html={logoSvg} />
37
+ {wordmark}</a
38
+ >
39
+ <slot />
40
+ </div>
38
41
  {
39
- hasBadges && (
40
- <div class="flex flex-wrap items-center gap-3">
41
- <slot name="badges" />
42
+ columns.map((column) => (
43
+ <div class="rw-ft-col">
44
+ <h4>{column.title}</h4>
45
+ {column.links.map((link) => (
46
+ <a
47
+ href={link.href}
48
+ target={link.external ? "_blank" : undefined}
49
+ rel={link.external ? "noopener noreferrer" : undefined}
50
+ >
51
+ {link.label}
52
+ </a>
53
+ ))}
42
54
  </div>
43
- )
55
+ ))
44
56
  }
45
57
  </div>
58
+ {
59
+ Astro.slots.has("bottom") && (
60
+ <div class="rw-ft-bottom">
61
+ <slot name="bottom" />
62
+ </div>
63
+ )
64
+ }
46
65
  </div>
47
66
  </footer>
67
+
68
+ <style>
69
+ .rw-ft {
70
+ border-top: 1px solid var(--rw-outline);
71
+ margin-top: 30px;
72
+ padding: 52px 0 0;
73
+ color: var(--rw-on-surface);
74
+ font-family: var(--rw-font-sans);
75
+ /* Container, not viewport: under Starlight the footer sits in the
76
+ * content column, not the full page width. */
77
+ container-type: inline-size;
78
+ }
79
+ /* Slotted content (blurb, bottom bar) doesn't carry this component's scope,
80
+ * so these reach it via :global, wrapped in :where() to stay at element
81
+ * specificity: any consumer class (e.g. a teaser paragraph) wins. */
82
+ :global(:where(.rw-ft) a) {
83
+ color: inherit;
84
+ text-decoration: none;
85
+ }
86
+ .rw-ft-wrap {
87
+ max-width: 1120px;
88
+ margin: 0 auto;
89
+ padding: 0 clamp(20px, 5vw, 40px);
90
+ }
91
+ .rw-ft-grid {
92
+ display: grid;
93
+ grid-template-columns: 1.6fr repeat(var(--rw-ft-cols), 1fr);
94
+ gap: 30px;
95
+ }
96
+ @container (max-width: 780px) {
97
+ .rw-ft-grid {
98
+ grid-template-columns: 1fr 1fr;
99
+ }
100
+ }
101
+ .rw-ft-mark {
102
+ display: flex;
103
+ align-items: center;
104
+ gap: 9px;
105
+ margin-bottom: 14px;
106
+ font-weight: 700;
107
+ font-size: 18px;
108
+ letter-spacing: -0.02em;
109
+ }
110
+ .rw-ft-logo {
111
+ display: inline-flex;
112
+ width: 21px;
113
+ height: 21px;
114
+ color: var(--rw-primary);
115
+ }
116
+ .rw-ft-logo :global(svg) {
117
+ width: 100%;
118
+ height: 100%;
119
+ }
120
+ :global(:where(.rw-ft-brand) p) {
121
+ margin: 0;
122
+ color: var(--rw-on-surface-muted);
123
+ font-size: 13.5px;
124
+ max-width: 30ch;
125
+ }
126
+ .rw-ft-col h4 {
127
+ margin: 0 0 14px;
128
+ font-family: var(--rw-font-mono);
129
+ font-size: 11px;
130
+ font-weight: 500;
131
+ letter-spacing: 0.1em;
132
+ text-transform: lowercase;
133
+ color: var(--rw-on-surface-faint);
134
+ }
135
+ .rw-ft-col h4::before {
136
+ content: "── ";
137
+ }
138
+ .rw-ft-col a {
139
+ display: block;
140
+ padding: 4px 0;
141
+ font-family: var(--rw-font-mono);
142
+ font-size: 13.5px;
143
+ color: var(--rw-on-surface-muted);
144
+ transition: color 0.15s;
145
+ }
146
+ .rw-ft-col a:hover,
147
+ :global(:where(.rw-ft) a:hover) {
148
+ color: var(--rw-primary);
149
+ }
150
+ .rw-ft-bottom {
151
+ display: flex;
152
+ flex-wrap: wrap;
153
+ gap: 8px 14px;
154
+ align-items: center;
155
+ margin-top: 44px;
156
+ padding: 16px 0;
157
+ border-top: 1px solid var(--rw-outline);
158
+ font-family: var(--rw-font-mono);
159
+ font-size: 12px;
160
+ color: var(--rw-on-surface-faint);
161
+ }
162
+ .rw-ft-bottom :global(.sep) {
163
+ opacity: 0.4;
164
+ }
165
+ </style>
@@ -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
+ }
@@ -2,21 +2,28 @@
2
2
  <!-- SPDX-License-Identifier: Apache-2.0 -->
3
3
 
4
4
  <script lang="ts">
5
+ import type { Component, Snippet } from "svelte";
5
6
  import Modal from "./Modal.svelte";
6
7
  import Button from "./Button.svelte";
7
-
8
- type AlertVariant = "danger" | "warning" | "info";
8
+ import type { ButtonVariant } from "./button-styles.js";
9
9
 
10
10
  interface Props {
11
11
  open?: boolean;
12
12
  title: string;
13
13
  description?: string | undefined;
14
+ size?: "sm" | "md" | "lg" | "xl" | "full";
14
15
  confirmLabel?: string;
15
16
  cancelLabel?: string;
16
- variant?: AlertVariant;
17
- onConfirm?: () => void;
17
+ confirmVariant?: ButtonVariant;
18
+ confirmDisabled?: boolean;
19
+ confirmIcon?: Component<{ size?: number }>;
20
+ /** May be async: the dialog shows a spinner and closes once it resolves.
21
+ * If it throws, the dialog stays open so the caller can surface the error. */
22
+ onConfirm?: () => void | Promise<void>;
18
23
  onCancel?: () => void;
19
- loading?: boolean;
24
+ children?: Snippet;
25
+ /** Replaces the default Cancel / Confirm buttons. */
26
+ footer?: Snippet;
20
27
  class?: string;
21
28
  }
22
29
 
@@ -24,40 +31,71 @@
24
31
  open = $bindable(false),
25
32
  title,
26
33
  description,
34
+ size = "sm",
27
35
  confirmLabel = "Confirm",
28
36
  cancelLabel = "Cancel",
29
- variant = "danger",
37
+ confirmVariant = "danger",
38
+ confirmDisabled = false,
39
+ confirmIcon: ConfirmIcon,
30
40
  onConfirm,
31
41
  onCancel,
32
- loading = false,
42
+ children,
43
+ footer: footerProp,
33
44
  class: className = "",
34
45
  }: Props = $props();
35
46
 
36
- const confirmVariantMap: Record<AlertVariant, "danger" | "primary"> = {
37
- danger: "danger",
38
- warning: "primary",
39
- info: "primary",
40
- };
47
+ let confirming = $state(false);
48
+
49
+ async function handleConfirm() {
50
+ if (confirming) return;
51
+ confirming = true;
52
+ try {
53
+ await onConfirm?.();
54
+ open = false;
55
+ } finally {
56
+ confirming = false;
57
+ }
58
+ }
41
59
 
42
60
  function handleCancel() {
61
+ if (confirming) return;
43
62
  open = false;
44
63
  onCancel?.();
45
64
  }
46
-
47
- function handleConfirm() {
48
- onConfirm?.();
49
- }
50
65
  </script>
51
66
 
52
- <Modal bind:open {title} {description} size="sm" closable onClose={handleCancel} class={className}>
67
+ {#snippet confirmIconSnippet()}
68
+ {#if ConfirmIcon}<ConfirmIcon size={16} />{/if}
69
+ {/snippet}
70
+
71
+ <Modal
72
+ bind:open
73
+ {title}
74
+ {description}
75
+ {size}
76
+ closable={!confirming}
77
+ onClose={handleCancel}
78
+ {children}
79
+ class={className}
80
+ >
53
81
  {#snippet footer()}
54
- <div class="flex items-center justify-end gap-3">
55
- <Button variant="secondary" onclick={handleCancel} disabled={loading}>
56
- {cancelLabel}
57
- </Button>
58
- <Button variant={confirmVariantMap[variant]} onclick={handleConfirm} {loading}>
59
- {confirmLabel}
60
- </Button>
61
- </div>
82
+ {#if footerProp}
83
+ {@render footerProp()}
84
+ {:else}
85
+ <div class="flex items-center justify-end gap-3">
86
+ <Button variant="secondary" onclick={handleCancel} disabled={confirming}>
87
+ {cancelLabel}
88
+ </Button>
89
+ <Button
90
+ variant={confirmVariant}
91
+ loading={confirming}
92
+ disabled={confirming || confirmDisabled}
93
+ onclick={() => void handleConfirm()}
94
+ icon={ConfirmIcon ? confirmIconSnippet : undefined}
95
+ >
96
+ {confirmLabel}
97
+ </Button>
98
+ </div>
99
+ {/if}
62
100
  {/snippet}
63
101
  </Modal>
@@ -17,7 +17,7 @@
17
17
  size?: ButtonSize;
18
18
  fullWidth?: boolean;
19
19
  loading?: boolean;
20
- icon?: Snippet;
20
+ icon?: Snippet | undefined;
21
21
  iconRight?: Snippet;
22
22
  children?: Snippet;
23
23
  }