@runwisp/ui 1.0.1 → 1.1.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.0.1",
4
+ "version": "1.1.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.1.0",
53
+ "@runwisp/typescript-config": "1.1.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.1.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>
@@ -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
  }