@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,142 @@
|
|
|
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
|
+
|
|
6
|
+
interface BrowserStub {
|
|
7
|
+
media: { matches: boolean };
|
|
8
|
+
classList: Set<string>;
|
|
9
|
+
fireMediaChange: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function stubBrowser(
|
|
13
|
+
options: {
|
|
14
|
+
matches?: boolean;
|
|
15
|
+
storage?: Record<string, string>;
|
|
16
|
+
throwOnStorage?: boolean;
|
|
17
|
+
} = {},
|
|
18
|
+
): BrowserStub {
|
|
19
|
+
const { matches = false, storage = {}, throwOnStorage = false } = options;
|
|
20
|
+
const listeners: (() => void)[] = [];
|
|
21
|
+
const media = { matches };
|
|
22
|
+
const classList = new Set<string>();
|
|
23
|
+
|
|
24
|
+
vi.stubGlobal("window", {});
|
|
25
|
+
vi.stubGlobal("matchMedia", () => ({
|
|
26
|
+
get matches() {
|
|
27
|
+
return media.matches;
|
|
28
|
+
},
|
|
29
|
+
addEventListener: (_event: string, cb: () => void) => {
|
|
30
|
+
listeners.push(cb);
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
vi.stubGlobal("localStorage", {
|
|
34
|
+
getItem: (key: string) => {
|
|
35
|
+
if (throwOnStorage) throw new Error("blocked");
|
|
36
|
+
return storage[key] ?? null;
|
|
37
|
+
},
|
|
38
|
+
setItem: (key: string, value: string) => {
|
|
39
|
+
if (throwOnStorage) throw new Error("blocked");
|
|
40
|
+
storage[key] = value;
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
vi.stubGlobal("document", {
|
|
44
|
+
documentElement: {
|
|
45
|
+
classList: {
|
|
46
|
+
toggle: (cls: string, on: boolean) => {
|
|
47
|
+
if (on) classList.add(cls);
|
|
48
|
+
else classList.delete(cls);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
media,
|
|
56
|
+
classList,
|
|
57
|
+
fireMediaChange: () => {
|
|
58
|
+
listeners.forEach((cb) => {
|
|
59
|
+
cb();
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("themeStore", () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
vi.resetModules();
|
|
68
|
+
});
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
vi.unstubAllGlobals();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("degrades outside a browser", async () => {
|
|
74
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
75
|
+
expect(themeStore.preference).toBe("auto");
|
|
76
|
+
expect(themeStore.resolved).toBe("light");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("resolves auto to the OS preference", async () => {
|
|
80
|
+
stubBrowser({ matches: true });
|
|
81
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
82
|
+
expect(themeStore.resolved).toBe("dark");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("reads a stored preference over auto", async () => {
|
|
86
|
+
stubBrowser({ storage: { "runwisp:theme": "dark" } });
|
|
87
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
88
|
+
expect(themeStore.preference).toBe("dark");
|
|
89
|
+
expect(themeStore.resolved).toBe("dark");
|
|
90
|
+
});
|
|
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
|
+
it("ignores an invalid stored value", async () => {
|
|
99
|
+
stubBrowser({ storage: { "runwisp:theme": "purple" } });
|
|
100
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
101
|
+
expect(themeStore.preference).toBe("auto");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("re-resolves live when the OS preference changes while in auto", async () => {
|
|
105
|
+
const env = stubBrowser({ matches: false });
|
|
106
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
107
|
+
expect(themeStore.resolved).toBe("light");
|
|
108
|
+
env.media.matches = true;
|
|
109
|
+
env.fireMediaChange();
|
|
110
|
+
expect(themeStore.resolved).toBe("dark");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("ignores OS changes once a manual preference is set", async () => {
|
|
114
|
+
const env = stubBrowser({ matches: false });
|
|
115
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
116
|
+
themeStore.set("light");
|
|
117
|
+
env.media.matches = true;
|
|
118
|
+
env.fireMediaChange();
|
|
119
|
+
expect(themeStore.resolved).toBe("light");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("persists an explicit preference and toggles the dark class", async () => {
|
|
123
|
+
const env = stubBrowser();
|
|
124
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
125
|
+
themeStore.set("dark");
|
|
126
|
+
expect(themeStore.resolved).toBe("dark");
|
|
127
|
+
expect(env.classList.has("dark")).toBe(true);
|
|
128
|
+
expect(localStorage.getItem("runwisp:theme")).toBe("dark");
|
|
129
|
+
|
|
130
|
+
themeStore.set("light");
|
|
131
|
+
expect(env.classList.has("dark")).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("still applies the preference for this page when storage write throws", async () => {
|
|
135
|
+
stubBrowser({ throwOnStorage: true });
|
|
136
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
137
|
+
expect(() => {
|
|
138
|
+
themeStore.set("dark");
|
|
139
|
+
}).not.toThrow();
|
|
140
|
+
expect(themeStore.resolved).toBe("dark");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
export type ThemePreference = "auto" | "light" | "dark";
|
|
5
|
+
export type ResolvedTheme = "light" | "dark";
|
|
6
|
+
|
|
7
|
+
// Shared by every RunWisp surface. Keep byte-for-byte in sync with the
|
|
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";
|
|
12
|
+
|
|
13
|
+
function isPreference(value: string | null): value is ThemePreference {
|
|
14
|
+
return value === "auto" || value === "light" || value === "dark";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readStored(): string | null {
|
|
18
|
+
try {
|
|
19
|
+
return localStorage.getItem(THEME_STORAGE_KEY) ?? localStorage.getItem(LEGACY_STORAGE_KEY);
|
|
20
|
+
} catch {
|
|
21
|
+
return null; // private mode / embedded contexts
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createThemeStore() {
|
|
26
|
+
const browser = typeof window !== "undefined";
|
|
27
|
+
let preference = $state<ThemePreference>("auto");
|
|
28
|
+
let resolved = $state<ResolvedTheme>("light");
|
|
29
|
+
|
|
30
|
+
const media = browser ? globalThis.matchMedia("(prefers-color-scheme: dark)") : null;
|
|
31
|
+
|
|
32
|
+
function apply(): void {
|
|
33
|
+
if (!browser) return;
|
|
34
|
+
if (preference === "auto") {
|
|
35
|
+
resolved = media?.matches === true ? "dark" : "light";
|
|
36
|
+
} else {
|
|
37
|
+
resolved = preference;
|
|
38
|
+
}
|
|
39
|
+
document.documentElement.classList.toggle("dark", resolved === "dark");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (browser) {
|
|
43
|
+
const stored = readStored();
|
|
44
|
+
if (isPreference(stored)) preference = stored;
|
|
45
|
+
apply();
|
|
46
|
+
// Re-resolve live when the OS preference changes while in `auto`.
|
|
47
|
+
media?.addEventListener("change", () => {
|
|
48
|
+
if (preference === "auto") apply();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
get preference(): ThemePreference {
|
|
54
|
+
return preference;
|
|
55
|
+
},
|
|
56
|
+
get resolved(): ResolvedTheme {
|
|
57
|
+
return resolved;
|
|
58
|
+
},
|
|
59
|
+
set(pref: ThemePreference): void {
|
|
60
|
+
preference = pref;
|
|
61
|
+
try {
|
|
62
|
+
localStorage.setItem(THEME_STORAGE_KEY, pref);
|
|
63
|
+
} catch {
|
|
64
|
+
// not persisted; still applied for this page
|
|
65
|
+
}
|
|
66
|
+
apply();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const themeStore = createThemeStore();
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
import DataGrid from "$lib/components/DataGrid.svelte";
|
|
7
7
|
import StatusIndicator from "$lib/components/StatusIndicator.svelte";
|
|
8
8
|
import Dropdown from "$lib/components/Dropdown.svelte";
|
|
9
|
+
import FilterBar from "$lib/components/FilterBar.svelte";
|
|
10
|
+
import { seedValues } from "$lib/components/filter-spec.js";
|
|
11
|
+
import { applyFilters } from "$lib/utils/filter.js";
|
|
9
12
|
import { Play, Pause, Trash2, SquarePen } from "@lucide/svelte";
|
|
10
13
|
|
|
11
14
|
const { Story } = defineMeta({
|
|
@@ -14,8 +17,10 @@
|
|
|
14
17
|
tags: ["autodocs"],
|
|
15
18
|
});
|
|
16
19
|
|
|
20
|
+
const nameColumn = { key: "name", label: "Task Name", sortable: true };
|
|
21
|
+
|
|
17
22
|
const columns = [
|
|
18
|
-
|
|
23
|
+
nameColumn,
|
|
19
24
|
{ key: "schedule", label: "Schedule" },
|
|
20
25
|
{ key: "lastRun", label: "Last Run", sortable: true },
|
|
21
26
|
{ key: "status", label: "Status" },
|
|
@@ -72,6 +77,31 @@
|
|
|
72
77
|
{ divider: true },
|
|
73
78
|
{ label: "Delete", icon: Trash2, danger: true },
|
|
74
79
|
];
|
|
80
|
+
|
|
81
|
+
/** @type {import("$lib/components/filter-spec.js").FilterField[]} */
|
|
82
|
+
const filterFields = [
|
|
83
|
+
{ type: "search", key: "q", fields: ["name", "schedule"], placeholder: "Search tasks..." },
|
|
84
|
+
{
|
|
85
|
+
type: "select",
|
|
86
|
+
key: "status",
|
|
87
|
+
label: "Status",
|
|
88
|
+
primary: true,
|
|
89
|
+
options: [
|
|
90
|
+
{ value: "all", label: "All" },
|
|
91
|
+
{ value: "success", label: "Success" },
|
|
92
|
+
{ value: "failed", label: "Failed" },
|
|
93
|
+
{ value: "running", label: "Running" },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<script>
|
|
100
|
+
let filterValues = $state(seedValues(filterFields));
|
|
101
|
+
/** @type {any[]} */
|
|
102
|
+
let selected = $state([]);
|
|
103
|
+
let clicked = $state("");
|
|
104
|
+
const filtered = $derived(applyFilters(tasks, filterFields, filterValues));
|
|
75
105
|
</script>
|
|
76
106
|
|
|
77
107
|
{#snippet statusCell(/** @type {any} */ row)}
|
|
@@ -115,6 +145,39 @@
|
|
|
115
145
|
|
|
116
146
|
<Story name="Paginated" args={{ columns, data: tasks, paginate: true, pageSize: 2 }} />
|
|
117
147
|
|
|
148
|
+
<Story name="Filters, Sort, Selection, Pagination" asChild>
|
|
149
|
+
<div class="flex flex-col gap-3">
|
|
150
|
+
<FilterBar fields={filterFields} bind:values={filterValues} />
|
|
151
|
+
<DataGrid
|
|
152
|
+
{columns}
|
|
153
|
+
data={filtered}
|
|
154
|
+
selectable
|
|
155
|
+
bind:selectedRows={selected}
|
|
156
|
+
sortKey="name"
|
|
157
|
+
sortDirection="asc"
|
|
158
|
+
paginate
|
|
159
|
+
pageSize={3}
|
|
160
|
+
pageSizeOptions={[3, 5, 10]}
|
|
161
|
+
onRowClick={(row) => (clicked = row.name)}
|
|
162
|
+
emptyMessage="No tasks match these filters"
|
|
163
|
+
/>
|
|
164
|
+
<p class="font-mono text-xs text-on-surface-muted">
|
|
165
|
+
selected: {selected.map((r) => r.name).join(", ") || "none"} · last row click: {clicked ||
|
|
166
|
+
"none"}
|
|
167
|
+
</p>
|
|
168
|
+
</div>
|
|
169
|
+
</Story>
|
|
170
|
+
|
|
171
|
+
<Story name="Bare With Footer, No Header" asChild>
|
|
172
|
+
<div class="rounded-[4px] border border-outline">
|
|
173
|
+
<DataGrid columns={[nameColumn]} data={tasks} bare showHeader={false}>
|
|
174
|
+
{#snippet footer()}
|
|
175
|
+
<button class="font-mono text-xs text-primary hover:underline">Load more</button>
|
|
176
|
+
{/snippet}
|
|
177
|
+
</DataGrid>
|
|
178
|
+
</div>
|
|
179
|
+
</Story>
|
|
180
|
+
|
|
118
181
|
<Story name="Striped" args={{ columns, data: tasks, striped: true }} />
|
|
119
182
|
|
|
120
183
|
<Story name="Compact" args={{ columns, data: tasks, compact: true }} />
|
|
@@ -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 FilterBar from "$lib/components/FilterBar.svelte";
|
|
7
|
+
import { seedValues } from "$lib/components/filter-spec.js";
|
|
8
|
+
|
|
9
|
+
const { Story } = defineMeta({
|
|
10
|
+
title: "Data/FilterBar",
|
|
11
|
+
component: FilterBar,
|
|
12
|
+
tags: ["autodocs"],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** @type {import("$lib/components/filter-spec.js").FilterField} */
|
|
16
|
+
const searchField = {
|
|
17
|
+
type: "search",
|
|
18
|
+
key: "q",
|
|
19
|
+
fields: ["name"],
|
|
20
|
+
placeholder: "Search tasks...",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** @type {import("$lib/components/filter-spec.js").FilterField[]} */
|
|
24
|
+
const fields = [
|
|
25
|
+
searchField,
|
|
26
|
+
{
|
|
27
|
+
type: "select",
|
|
28
|
+
key: "status",
|
|
29
|
+
label: "Status",
|
|
30
|
+
primary: true,
|
|
31
|
+
options: [
|
|
32
|
+
{ value: "all", label: "All" },
|
|
33
|
+
{ value: "success", label: "Success" },
|
|
34
|
+
{ value: "failed", label: "Failed" },
|
|
35
|
+
{ value: "running", label: "Running" },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{ type: "number", key: "exitCode", label: "Exit code" },
|
|
39
|
+
{ type: "daterange", key: "lastRun", label: "Last run" },
|
|
40
|
+
];
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<script>
|
|
44
|
+
let values = $state(seedValues(fields));
|
|
45
|
+
let preset = $state(seedValues(fields, { status: "failed", exitCode: "1" }));
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<Story name="Default" asChild>
|
|
49
|
+
<div class="flex flex-col gap-4">
|
|
50
|
+
<FilterBar {fields} bind:values />
|
|
51
|
+
<pre class="font-mono text-xs text-on-surface-muted">{JSON.stringify(values, null, 2)}</pre>
|
|
52
|
+
</div>
|
|
53
|
+
</Story>
|
|
54
|
+
|
|
55
|
+
<Story name="Pre-filled (URL restore)" asChild>
|
|
56
|
+
<FilterBar {fields} bind:values={preset} />
|
|
57
|
+
</Story>
|
|
58
|
+
|
|
59
|
+
<Story name="Search Only" asChild>
|
|
60
|
+
<FilterBar fields={[searchField]} values={seedValues([searchField])} />
|
|
61
|
+
</Story>
|
package/src/stories/Welcome.mdx
CHANGED
|
@@ -185,7 +185,7 @@ import Logo from "../../static/runwisp_c.svg";
|
|
|
185
185
|
</div>
|
|
186
186
|
<div className="rw-card">
|
|
187
187
|
<h3>Data</h3>
|
|
188
|
-
<p>DataGrid, Avatar, StatusIndicator, StatCard, EmptyState</p>
|
|
188
|
+
<p>DataGrid, FilterBar, Avatar, StatusIndicator, StatCard, EmptyState</p>
|
|
189
189
|
</div>
|
|
190
190
|
<div className="rw-card">
|
|
191
191
|
<h3>Navigation</h3>
|
package/src/astro/CTABand.astro
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
3
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
-
|
|
5
|
-
interface Props {
|
|
6
|
-
heading: string;
|
|
7
|
-
sub?: string;
|
|
8
|
-
tone?: "default" | "primary";
|
|
9
|
-
class?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const { heading, sub, tone = "default", class: className = "" } = Astro.props;
|
|
13
|
-
|
|
14
|
-
const surface =
|
|
15
|
-
tone === "primary" ? "bg-primary text-on-primary" : "bg-surface-raised text-on-surface";
|
|
16
|
-
const subTone = tone === "primary" ? "text-on-primary/80" : "text-on-surface-muted";
|
|
17
|
-
const hasCtas = Astro.slots.has("ctas");
|
|
18
|
-
---
|
|
19
|
-
|
|
20
|
-
<section class:list={[className]}>
|
|
21
|
-
<div class="mx-auto max-w-5xl px-6 py-16 sm:py-20">
|
|
22
|
-
<div
|
|
23
|
-
class:list={[
|
|
24
|
-
surface,
|
|
25
|
-
"rounded-[4px] border border-outline px-6 py-12 text-center shadow-sm sm:px-10",
|
|
26
|
-
]}
|
|
27
|
-
>
|
|
28
|
-
<h2 class="font-mono text-3xl font-extrabold sm:text-4xl">{heading}</h2>
|
|
29
|
-
{sub && <p class:list={[subTone, "mt-4 font-sans text-lg"]}>{sub}</p>}
|
|
30
|
-
{
|
|
31
|
-
hasCtas && (
|
|
32
|
-
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
33
|
-
<slot name="ctas" />
|
|
34
|
-
</div>
|
|
35
|
-
)
|
|
36
|
-
}
|
|
37
|
-
</div>
|
|
38
|
-
</div>
|
|
39
|
-
</section>
|
package/src/astro/Hero.astro
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
3
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
-
|
|
5
|
-
interface Props {
|
|
6
|
-
headline: string;
|
|
7
|
-
sub?: string;
|
|
8
|
-
eyebrow?: string;
|
|
9
|
-
align?: "left" | "center";
|
|
10
|
-
size?: "md" | "lg";
|
|
11
|
-
class?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const { headline, sub, eyebrow, align = "left", size = "lg", class: className = "" } = Astro.props;
|
|
15
|
-
|
|
16
|
-
const padding = size === "lg" ? "py-20 sm:py-24 lg:py-32" : "py-14 sm:py-16 lg:py-20";
|
|
17
|
-
const headlineSize =
|
|
18
|
-
size === "lg" ? "text-4xl sm:text-5xl lg:text-6xl" : "text-3xl sm:text-4xl lg:text-5xl";
|
|
19
|
-
const hasMedia = Astro.slots.has("media");
|
|
20
|
-
const hasCtas = Astro.slots.has("ctas");
|
|
21
|
-
const layout = hasMedia ? "grid items-center gap-12 lg:grid-cols-2 lg:gap-16" : "mx-auto max-w-3xl";
|
|
22
|
-
const textAlign = align === "center" && !hasMedia ? "text-center" : "";
|
|
23
|
-
const ctaAlign = align === "center" && !hasMedia ? "justify-center" : "";
|
|
24
|
-
---
|
|
25
|
-
|
|
26
|
-
<section class:list={["relative isolate overflow-hidden", className]}>
|
|
27
|
-
<div class:list={["mx-auto max-w-6xl px-6", padding]}>
|
|
28
|
-
<div class={layout}>
|
|
29
|
-
<div class={textAlign}>
|
|
30
|
-
{
|
|
31
|
-
eyebrow && (
|
|
32
|
-
<p class="text-info mb-4 font-mono text-[11.5px] font-medium tracking-[0.16em] uppercase">
|
|
33
|
-
{eyebrow}
|
|
34
|
-
</p>
|
|
35
|
-
)
|
|
36
|
-
}
|
|
37
|
-
<h1 class:list={["text-on-surface font-mono font-extrabold", headlineSize]}>
|
|
38
|
-
{headline}
|
|
39
|
-
</h1>
|
|
40
|
-
{sub && <p class="text-on-surface-muted mt-6 font-sans text-lg sm:text-xl">{sub}</p>}
|
|
41
|
-
{
|
|
42
|
-
hasCtas && (
|
|
43
|
-
<div class:list={["mt-8 flex flex-wrap items-center gap-3", ctaAlign]}>
|
|
44
|
-
<slot name="ctas" />
|
|
45
|
-
</div>
|
|
46
|
-
)
|
|
47
|
-
}
|
|
48
|
-
</div>
|
|
49
|
-
{
|
|
50
|
-
hasMedia && (
|
|
51
|
-
<div class="lg:justify-self-end">
|
|
52
|
-
<slot name="media" />
|
|
53
|
-
</div>
|
|
54
|
-
)
|
|
55
|
-
}
|
|
56
|
-
</div>
|
|
57
|
-
</div>
|
|
58
|
-
</section>
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
3
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
-
import { Logo } from "@runwisp/ui";
|
|
5
|
-
|
|
6
|
-
interface Props {
|
|
7
|
-
homeHref?: string;
|
|
8
|
-
wordmark?: string;
|
|
9
|
-
class?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const { homeHref = "/", wordmark = "RunWisp", class: className = "" } = Astro.props;
|
|
13
|
-
|
|
14
|
-
const hasNav = Astro.slots.has("nav");
|
|
15
|
-
const hasActions = Astro.slots.has("actions");
|
|
16
|
-
---
|
|
17
|
-
|
|
18
|
-
<header class:list={["rw-bg-glass sticky top-0 z-40 border-b border-outline", className]}>
|
|
19
|
-
<div class="mx-auto flex h-[52px] max-w-6xl items-center justify-between gap-6 px-6">
|
|
20
|
-
<a
|
|
21
|
-
href={homeHref}
|
|
22
|
-
class="text-on-surface flex items-center gap-2"
|
|
23
|
-
aria-label={`${wordmark} home`}
|
|
24
|
-
>
|
|
25
|
-
<Logo size="md" />
|
|
26
|
-
<span class="font-mono font-bold tracking-tight">{wordmark}</span>
|
|
27
|
-
</a>
|
|
28
|
-
{
|
|
29
|
-
hasNav && (
|
|
30
|
-
<nav
|
|
31
|
-
class="text-on-surface-muted hidden gap-6 font-mono text-sm sm:flex [&_a]:[&_a:hover]:text-primary"
|
|
32
|
-
aria-label="Primary"
|
|
33
|
-
>
|
|
34
|
-
<slot name="nav" />
|
|
35
|
-
</nav>
|
|
36
|
-
)
|
|
37
|
-
}
|
|
38
|
-
{
|
|
39
|
-
hasActions && (
|
|
40
|
-
<div class="flex items-center gap-2">
|
|
41
|
-
<slot name="actions" />
|
|
42
|
-
</div>
|
|
43
|
-
)
|
|
44
|
-
}
|
|
45
|
-
</div>
|
|
46
|
-
</header>
|