@runwisp/ui 0.4.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 (108) hide show
  1. package/LICENSE +215 -0
  2. package/README.md +20 -0
  3. package/package.json +128 -0
  4. package/src/app.d.ts +8 -0
  5. package/src/app.html +12 -0
  6. package/src/astro/CTABand.astro +32 -0
  7. package/src/astro/Hero.astro +58 -0
  8. package/src/astro/SiteFooter.astro +47 -0
  9. package/src/astro/SiteHeader.astro +46 -0
  10. package/src/lib/actions/portal.ts +16 -0
  11. package/src/lib/assets/runwisp-logo.svg +16 -0
  12. package/src/lib/components/Accordion.svelte +20 -0
  13. package/src/lib/components/AccordionItem.svelte +74 -0
  14. package/src/lib/components/Alert.svelte +107 -0
  15. package/src/lib/components/AlertDialog.svelte +63 -0
  16. package/src/lib/components/Avatar.svelte +88 -0
  17. package/src/lib/components/Badge.svelte +131 -0
  18. package/src/lib/components/Breadcrumb.svelte +58 -0
  19. package/src/lib/components/Button.svelte +69 -0
  20. package/src/lib/components/Card.svelte +70 -0
  21. package/src/lib/components/Checkbox.svelte +90 -0
  22. package/src/lib/components/CodeBlock.svelte +66 -0
  23. package/src/lib/components/CronInput.svelte +130 -0
  24. package/src/lib/components/DataGrid.svelte +368 -0
  25. package/src/lib/components/Drawer.svelte +156 -0
  26. package/src/lib/components/Dropdown.svelte +195 -0
  27. package/src/lib/components/DurationInput.svelte +170 -0
  28. package/src/lib/components/EmptyState.svelte +46 -0
  29. package/src/lib/components/ErrorState.svelte +73 -0
  30. package/src/lib/components/FeatureCard.svelte +35 -0
  31. package/src/lib/components/FeatureGrid.svelte +37 -0
  32. package/src/lib/components/FormField.svelte +53 -0
  33. package/src/lib/components/Heading.svelte +46 -0
  34. package/src/lib/components/Input.svelte +114 -0
  35. package/src/lib/components/Kbd.svelte +31 -0
  36. package/src/lib/components/Link.svelte +56 -0
  37. package/src/lib/components/LinkButton.svelte +55 -0
  38. package/src/lib/components/LogConsole.svelte +424 -0
  39. package/src/lib/components/Logo.svelte +34 -0
  40. package/src/lib/components/Modal.svelte +138 -0
  41. package/src/lib/components/PageContainer.svelte +38 -0
  42. package/src/lib/components/Pagination.svelte +96 -0
  43. package/src/lib/components/Popover.svelte +130 -0
  44. package/src/lib/components/Progress.svelte +68 -0
  45. package/src/lib/components/Prose.svelte +43 -0
  46. package/src/lib/components/Radio.svelte +82 -0
  47. package/src/lib/components/RadioGroup.svelte +53 -0
  48. package/src/lib/components/Select.svelte +454 -0
  49. package/src/lib/components/Separator.svelte +30 -0
  50. package/src/lib/components/Skeleton.svelte +75 -0
  51. package/src/lib/components/Sparkline.svelte +58 -0
  52. package/src/lib/components/Spinner.svelte +44 -0
  53. package/src/lib/components/StatusIndicator.svelte +203 -0
  54. package/src/lib/components/Table.svelte +19 -0
  55. package/src/lib/components/TableBody.svelte +17 -0
  56. package/src/lib/components/TableCell.svelte +34 -0
  57. package/src/lib/components/TableHead.svelte +19 -0
  58. package/src/lib/components/TableRow.svelte +22 -0
  59. package/src/lib/components/Tabs.svelte +148 -0
  60. package/src/lib/components/Text.svelte +57 -0
  61. package/src/lib/components/Textarea.svelte +81 -0
  62. package/src/lib/components/ThemeToggle.svelte +47 -0
  63. package/src/lib/components/TimezoneSelect.svelte +127 -0
  64. package/src/lib/components/ToastContainer.svelte +84 -0
  65. package/src/lib/components/Toggle.svelte +84 -0
  66. package/src/lib/components/Tooltip.svelte +56 -0
  67. package/src/lib/components/button-styles.ts +51 -0
  68. package/src/lib/components/dashboard/RunDetailPanel.svelte +252 -0
  69. package/src/lib/components/dashboard/RunsList.svelte +331 -0
  70. package/src/lib/components/dashboard/run-helpers.ts +12 -0
  71. package/src/lib/components/dashboard/status-config.ts +141 -0
  72. package/src/lib/components/dashboard/types.ts +35 -0
  73. package/src/lib/index.ts +122 -0
  74. package/src/lib/log-console/LogCache.svelte.ts +93 -0
  75. package/src/lib/log-console/LogFetcher.svelte.ts +179 -0
  76. package/src/lib/log-console/types.ts +24 -0
  77. package/src/lib/theme-tokens.css +235 -0
  78. package/src/lib/theme.css +276 -0
  79. package/src/lib/utils/error.ts +18 -0
  80. package/src/lib/utils/format.ts +76 -0
  81. package/src/lib/utils/id.ts +6 -0
  82. package/src/lib/utils/toast.svelte.ts +87 -0
  83. package/src/lib/utils/toast.ts +60 -0
  84. package/src/routes/+layout.svelte +10 -0
  85. package/src/routes/+page.svelte +5 -0
  86. package/src/routes/layout.css +3 -0
  87. package/src/stories/Alert.stories.svelte +64 -0
  88. package/src/stories/Avatar.stories.svelte +76 -0
  89. package/src/stories/Badge.stories.svelte +76 -0
  90. package/src/stories/Breadcrumb.stories.svelte +64 -0
  91. package/src/stories/Button.stories.svelte +101 -0
  92. package/src/stories/Card.stories.svelte +106 -0
  93. package/src/stories/Checkbox.stories.svelte +77 -0
  94. package/src/stories/DataGrid.stories.svelte +130 -0
  95. package/src/stories/Dropdown.stories.svelte +81 -0
  96. package/src/stories/EmptyState.stories.svelte +74 -0
  97. package/src/stories/Input.stories.svelte +88 -0
  98. package/src/stories/Logo.stories.svelte +82 -0
  99. package/src/stories/Modal.stories.svelte +101 -0
  100. package/src/stories/Progress.stories.svelte +65 -0
  101. package/src/stories/Select.stories.svelte +158 -0
  102. package/src/stories/Tabs.stories.svelte +103 -0
  103. package/src/stories/Textarea.stories.svelte +45 -0
  104. package/src/stories/Toggle.stories.svelte +74 -0
  105. package/src/stories/Tooltip.stories.svelte +77 -0
  106. package/src/stories/Welcome.mdx +241 -0
  107. package/src/stories/assets/runwisp_c.svg +16 -0
  108. package/src/svelte-shims.d.ts +18 -0
@@ -0,0 +1,424 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import { untrack } from "svelte";
6
+ import Convert from "ansi-to-html";
7
+ import { LogCache } from "../log-console/LogCache.svelte.js";
8
+ import { LogFetcher } from "../log-console/LogFetcher.svelte.js";
9
+ import type { FetchLogsFn, LogEvent } from "../log-console/types.js";
10
+ import { formatBytes } from "../utils/format.js";
11
+
12
+ // Convert ANSI escape sequences in a single log line to safe HTML spans.
13
+ // A fresh converter per call ensures no colour state leaks between
14
+ // independently virtualised lines.
15
+ function ansiLineToHtml(text: string): string {
16
+ return new Convert({ escapeXML: true }).toHtml(text);
17
+ }
18
+
19
+ interface Props {
20
+ fetchLogs?: FetchLogsFn;
21
+ chunkSize?: number;
22
+ lineHeight?: number;
23
+ class?: string;
24
+ }
25
+
26
+ let { fetchLogs, chunkSize = 4096, lineHeight = 20, class: className = "" }: Props = $props();
27
+
28
+ const cache = new LogCache();
29
+ let fetcher: LogFetcher | null = $state(null);
30
+ let containerEl: HTMLDivElement | null = $state(null);
31
+ let scrollTop = $state(0);
32
+ let containerHeight = $state(0);
33
+ let isAutoScroll = $state(true);
34
+ let userScrolledUp = $state(false);
35
+ let isStreaming = $derived(!cache.finished && cache.totalLines > 0);
36
+
37
+ const OVERSCAN = 50;
38
+ const BLANK_LINES_AT_END = 3;
39
+
40
+ let truncationBannerHeight = $derived(cache.firstAvailableLine > 0 ? lineHeight : 0);
41
+
42
+ // Convert a global line number to a pixel Y position.
43
+ function lineTop(lineNum: number): number {
44
+ return (lineNum - cache.firstAvailableLine) * lineHeight + truncationBannerHeight;
45
+ }
46
+
47
+ let visibleStart = $derived(
48
+ Math.max(
49
+ cache.firstAvailableLine,
50
+ cache.firstAvailableLine +
51
+ Math.floor(Math.max(0, scrollTop - truncationBannerHeight) / lineHeight) -
52
+ OVERSCAN,
53
+ ),
54
+ );
55
+ let visibleEnd = $derived(
56
+ Math.max(
57
+ cache.firstAvailableLine,
58
+ Math.min(
59
+ Math.max(0, cache.totalLines - 1),
60
+ cache.firstAvailableLine +
61
+ Math.ceil(
62
+ Math.max(0, scrollTop - truncationBannerHeight + containerHeight) /
63
+ lineHeight,
64
+ ) +
65
+ OVERSCAN,
66
+ ),
67
+ ),
68
+ );
69
+
70
+ let totalHeight = $derived.by(() => {
71
+ const availableLines = cache.totalLines - cache.firstAvailableLine;
72
+ const truncationBannerHeight = cache.firstAvailableLine > 0 ? lineHeight : 0;
73
+ const linesHeight = availableLines * lineHeight + truncationBannerHeight;
74
+ const streamingHeight = !cache.finished && cache.totalLines > 0 ? lineHeight : 0;
75
+ const blankHeight = cache.totalLines > 0 ? BLANK_LINES_AT_END * lineHeight : 0;
76
+ return Math.max(linesHeight + streamingHeight + blankHeight, containerHeight);
77
+ });
78
+
79
+ let gutterWidth = $derived(Math.max(4, String(cache.totalLines || 1).length) * 10 + 16);
80
+
81
+ let renderedLines = $derived.by(() => {
82
+ const lines: Array<{ num: number; text: string | undefined }> = [];
83
+ const start = Math.max(cache.firstAvailableLine, visibleStart);
84
+ const end = Math.min(cache.totalLines - 1, visibleEnd);
85
+
86
+ for (let i = start; i <= end; i++) {
87
+ lines.push({
88
+ num: i,
89
+ text: cache.lines.get(i),
90
+ });
91
+ }
92
+ return lines;
93
+ });
94
+
95
+ // The fetcher serves on-demand scroll-up loads only; the parent seeds
96
+ // the initial cache via onStream (typically a tail fetch) so the
97
+ // viewport lands at the end of the log without racing the auto-scroll.
98
+ $effect(() => {
99
+ const fn = fetchLogs;
100
+ const cs = chunkSize;
101
+ fetcher = new LogFetcher(cache, fn, cs, () => {
102
+ if (isAutoScroll && !userScrolledUp) {
103
+ requestAnimationFrame(() => scrollToBottom());
104
+ }
105
+ });
106
+
107
+ return () => {
108
+ fetcher?.destroy();
109
+ };
110
+ });
111
+
112
+ // Request missing data for the visible range (read-only w.r.t. cache).
113
+ $effect(() => {
114
+ if (fetcher && cache.totalLines > 0) {
115
+ fetcher.pruneQueue(visibleStart, visibleEnd);
116
+ untrack(() => {
117
+ fetcher!.maybeRequestMissing(visibleStart, visibleEnd);
118
+ });
119
+ }
120
+ });
121
+
122
+ // Prune stale cache entries — separate from reads to avoid a read-write cycle on SvelteMap.
123
+ $effect(() => {
124
+ const vs = visibleStart;
125
+ const ve = visibleEnd;
126
+ untrack(() => cache.prune(vs, ve));
127
+ });
128
+
129
+ let prevTotalLines = 0; // plain variable — intentionally non-reactive
130
+
131
+ $effect(() => {
132
+ const currentTotal = cache.totalLines;
133
+ if (currentTotal > prevTotalLines && isAutoScroll && !userScrolledUp) {
134
+ requestAnimationFrame(() => scrollToBottom());
135
+ }
136
+ prevTotalLines = currentTotal;
137
+ });
138
+
139
+ function onScroll(e: Event) {
140
+ const target = e.target as HTMLDivElement;
141
+ scrollTop = target.scrollTop;
142
+
143
+ const scrollHeight = target.scrollHeight;
144
+ const clientHeight = target.clientHeight;
145
+ const currentScroll = target.scrollTop;
146
+ const distanceFromBottom = scrollHeight - currentScroll - clientHeight;
147
+
148
+ const atBottom = distanceFromBottom < lineHeight * 2;
149
+
150
+ if (atBottom) {
151
+ userScrolledUp = false;
152
+ isAutoScroll = true;
153
+ } else if (isAutoScroll) {
154
+ userScrolledUp = true;
155
+ isAutoScroll = false;
156
+ }
157
+ }
158
+
159
+ function scrollToBottom(smooth = false) {
160
+ if (containerEl) {
161
+ if (smooth) {
162
+ containerEl.scrollTo({
163
+ top: containerEl.scrollHeight,
164
+ behavior: "smooth",
165
+ });
166
+ } else {
167
+ containerEl.scrollTop = containerEl.scrollHeight;
168
+ }
169
+ }
170
+ }
171
+
172
+ function enableAutoScroll() {
173
+ isAutoScroll = true;
174
+ userScrolledUp = false;
175
+ scrollToBottom();
176
+ }
177
+
178
+ function onResize() {
179
+ if (containerEl) {
180
+ containerHeight = containerEl.clientHeight;
181
+ }
182
+ }
183
+
184
+ export function onStream(event: LogEvent) {
185
+ const prevTotal = cache.totalLines;
186
+ cache.applyEvent(event);
187
+
188
+ if (cache.totalLines > prevTotal && isAutoScroll && !userScrolledUp) {
189
+ requestAnimationFrame(() => scrollToBottom());
190
+ }
191
+
192
+ if (fetcher) {
193
+ fetcher.maybeRequestMissing(visibleStart, visibleEnd);
194
+ }
195
+ }
196
+
197
+ export function reset() {
198
+ cache.reset();
199
+ isAutoScroll = true;
200
+ userScrolledUp = false;
201
+ scrollTop = 0;
202
+ }
203
+
204
+ $effect(() => {
205
+ if (!containerEl) return;
206
+ containerHeight = containerEl.clientHeight;
207
+
208
+ const resizeObserver = new ResizeObserver(() => {
209
+ onResize();
210
+ });
211
+ resizeObserver.observe(containerEl);
212
+
213
+ return () => {
214
+ resizeObserver.disconnect();
215
+ };
216
+ });
217
+ </script>
218
+
219
+ <div
220
+ class="log-console relative flex h-full w-full flex-col overflow-hidden bg-slate-950 font-mono text-sm {className}"
221
+ >
222
+ <div bind:this={containerEl} class="flex-1 overflow-auto" onscroll={onScroll}>
223
+ <div class="relative" style="height: {totalHeight}px; min-height: 100%;">
224
+ {#if cache.firstAvailableLine > 0}
225
+ <div
226
+ class="absolute right-0 left-0 flex items-center bg-amber-950/60 text-amber-400"
227
+ style="top: 0px; height: {lineHeight}px;"
228
+ >
229
+ <div
230
+ class="flex-shrink-0 pr-3 text-right select-none"
231
+ style="width: {gutterWidth}px;"
232
+ ></div>
233
+ <div class="flex-1 truncate pr-4 text-xs">
234
+ Log truncated: {cache.firstAvailableLine.toLocaleString()} earlier line{cache.firstAvailableLine ===
235
+ 1
236
+ ? ""
237
+ : "s"} removed due to size limits
238
+ </div>
239
+ </div>
240
+ {/if}
241
+
242
+ {#each renderedLines as line (line.num)}
243
+ <div
244
+ class="log-line absolute right-0 left-0 flex items-center hover:bg-slate-900/50"
245
+ style="top: {lineTop(line.num)}px; height: {lineHeight}px;"
246
+ >
247
+ <div
248
+ class="flex-shrink-0 pr-3 text-right text-slate-600 select-none"
249
+ style="width: {gutterWidth}px;"
250
+ >
251
+ {line.num + 1}
252
+ </div>
253
+ <div class="flex-1 truncate pr-4 text-slate-300">
254
+ {#if line.text !== undefined}
255
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
256
+ <span class="whitespace-pre">{@html ansiLineToHtml(line.text)}</span>
257
+ {:else}
258
+ <span class="text-slate-700 italic">Loading...</span>
259
+ {/if}
260
+ </div>
261
+ </div>
262
+ {/each}
263
+
264
+ {#if isStreaming && cache.totalLines > 0}
265
+ <div
266
+ class="streaming-indicator absolute right-0 left-0 flex items-center"
267
+ style="top: {lineTop(cache.totalLines)}px; height: {lineHeight}px;"
268
+ >
269
+ <div
270
+ class="flex-shrink-0 pr-3 text-right select-none"
271
+ style="width: {gutterWidth}px;"
272
+ ></div>
273
+ <div class="flex items-center gap-1 text-slate-500">
274
+ <span class="dot" style="animation-delay: 0ms;"></span>
275
+ <span class="dot" style="animation-delay: 150ms;"></span>
276
+ <span class="dot" style="animation-delay: 300ms;"></span>
277
+ </div>
278
+ </div>
279
+ {/if}
280
+
281
+ {#if cache.totalLines > 0}
282
+ {#each Array.from({ length: BLANK_LINES_AT_END }, (_, i) => i) as i (i)}
283
+ <div
284
+ class="absolute right-0 left-0 flex items-center"
285
+ style="top: {lineTop(
286
+ cache.totalLines + (isStreaming ? 1 : 0) + i,
287
+ )}px; height: {lineHeight}px;"
288
+ >
289
+ <div
290
+ class="flex-shrink-0 pr-3 text-right text-slate-800 opacity-0 select-none"
291
+ style="width: {gutterWidth}px;"
292
+ >
293
+ ~
294
+ </div>
295
+ </div>
296
+ {/each}
297
+ {/if}
298
+
299
+ {#if cache.totalLines === 0 && !fetcher?.isFetching}
300
+ <div class="absolute inset-0 flex items-center justify-center text-slate-600">
301
+ <div class="text-center">
302
+ <div class="mb-1 text-lg">No output yet</div>
303
+ <div class="text-sm text-slate-700">Waiting for logs...</div>
304
+ </div>
305
+ </div>
306
+ {/if}
307
+
308
+ {#if cache.totalLines === 0 && fetcher?.isFetching}
309
+ <div class="absolute inset-0 flex items-center justify-center text-slate-600">
310
+ <div class="flex items-center gap-2">
311
+ <span class="dot" style="animation-delay: 0ms;"></span>
312
+ <span class="dot" style="animation-delay: 150ms;"></span>
313
+ <span class="dot" style="animation-delay: 300ms;"></span>
314
+ <span class="ml-2">Loading logs...</span>
315
+ </div>
316
+ </div>
317
+ {/if}
318
+ </div>
319
+ </div>
320
+
321
+ {#if userScrolledUp && cache.totalLines > 0}
322
+ <button
323
+ class="absolute right-4 bottom-12 z-10 flex items-center gap-2 rounded-full border
324
+ border-slate-700 bg-slate-800
325
+ px-3 py-1.5 text-xs
326
+ font-medium text-slate-300 shadow-lg
327
+ transition-all duration-200 hover:scale-105
328
+ hover:bg-slate-700 active:scale-95"
329
+ onclick={enableAutoScroll}
330
+ >
331
+ <svg
332
+ class="h-3.5 w-3.5"
333
+ viewBox="0 0 24 24"
334
+ fill="none"
335
+ stroke="currentColor"
336
+ stroke-width="2"
337
+ >
338
+ <path d="M12 5v14M19 12l-7 7-7-7" />
339
+ </svg>
340
+ Scroll to bottom
341
+ </button>
342
+ {/if}
343
+
344
+ <div
345
+ class="flex flex-shrink-0 items-center justify-between border-t border-slate-800
346
+ bg-slate-900/80 px-3 py-1.5 text-xs text-slate-500"
347
+ >
348
+ <div class="flex items-center gap-3">
349
+ {#if isStreaming}
350
+ <div class="flex items-center gap-1.5 text-aurora-400">
351
+ <div class="h-1.5 w-1.5 animate-pulse rounded-full bg-aurora-400"></div>
352
+ Streaming
353
+ </div>
354
+ {:else if cache.finished}
355
+ <span class="text-slate-600">Stream ended</span>
356
+ {/if}
357
+ {#if fetcher?.isFetching}
358
+ <span class="text-slate-600">Fetching...</span>
359
+ {/if}
360
+ </div>
361
+ <div class="flex items-center gap-4">
362
+ <span>{cache.totalLines.toLocaleString()} lines</span>
363
+ {#if cache.totalBytes > 0}
364
+ <span>{formatBytes(cache.totalBytes)}</span>
365
+ {/if}
366
+ {#if isAutoScroll}
367
+ <span class="text-aurora-500">Auto-scroll</span>
368
+ {/if}
369
+ </div>
370
+ </div>
371
+ </div>
372
+
373
+ <style>
374
+ .log-console {
375
+ --log-line-height: 20px;
376
+ }
377
+
378
+ .dot {
379
+ display: inline-block;
380
+ width: 4px;
381
+ height: 4px;
382
+ border-radius: 50%;
383
+ background-color: currentColor;
384
+ animation: dot-pulse 1s ease-in-out infinite;
385
+ }
386
+
387
+ @keyframes dot-pulse {
388
+ 0%,
389
+ 60%,
390
+ 100% {
391
+ opacity: 0.3;
392
+ transform: scale(0.8);
393
+ }
394
+ 30% {
395
+ opacity: 1;
396
+ transform: scale(1);
397
+ }
398
+ }
399
+
400
+ .log-console ::-webkit-scrollbar {
401
+ width: 10px;
402
+ height: 10px;
403
+ }
404
+
405
+ .log-console ::-webkit-scrollbar-track {
406
+ background: transparent;
407
+ }
408
+
409
+ .log-console ::-webkit-scrollbar-thumb {
410
+ background-color: var(--color-mist-700);
411
+ border-radius: 5px;
412
+ border: 2px solid transparent;
413
+ background-clip: content-box;
414
+ }
415
+
416
+ .log-console ::-webkit-scrollbar-thumb:hover {
417
+ background-color: var(--color-mist-600);
418
+ background-clip: content-box;
419
+ }
420
+
421
+ .log-console ::-webkit-scrollbar-corner {
422
+ background: transparent;
423
+ }
424
+ </style>
@@ -0,0 +1,34 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import logoSvg from "../assets/runwisp-logo.svg?raw";
6
+
7
+ interface Props {
8
+ size?: "xs" | "sm" | "md" | "lg" | "xl";
9
+ class?: string;
10
+ }
11
+
12
+ let { size = "md", class: className = "" }: Props = $props();
13
+
14
+ const sizeClasses = {
15
+ xs: "w-4 h-4",
16
+ sm: "w-5 h-5",
17
+ md: "w-6 h-6",
18
+ lg: "w-8 h-8",
19
+ xl: "w-12 h-12",
20
+ };
21
+ </script>
22
+
23
+ <span class="inline-flex {sizeClasses[size]} {className}" aria-hidden="true">
24
+ <!-- SVG body is a static, repo-vendored asset imported via Vite's ?raw loader. -->
25
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
26
+ {@html logoSvg}
27
+ </span>
28
+
29
+ <style>
30
+ span :global(svg) {
31
+ width: 100%;
32
+ height: 100%;
33
+ }
34
+ </style>
@@ -0,0 +1,138 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import type { Snippet } from "svelte";
6
+ import { X } from "@lucide/svelte";
7
+
8
+ interface Props {
9
+ open?: boolean;
10
+ title?: string;
11
+ description?: string | undefined;
12
+ size?: "sm" | "md" | "lg" | "xl" | "full";
13
+ closable?: boolean;
14
+ onClose?: () => void;
15
+ header?: Snippet;
16
+ footer?: Snippet;
17
+ children?: Snippet;
18
+ class?: string;
19
+ }
20
+
21
+ import { portal } from "../actions/portal.js";
22
+
23
+ let {
24
+ open = $bindable(false),
25
+ title,
26
+ description,
27
+ size = "md",
28
+ closable = true,
29
+ onClose,
30
+ header,
31
+ footer,
32
+ children,
33
+ class: className = "",
34
+ }: Props = $props();
35
+
36
+ const sizeClasses: Record<string, string> = {
37
+ sm: "max-w-sm",
38
+ md: "max-w-md",
39
+ lg: "max-w-lg",
40
+ xl: "max-w-xl",
41
+ full: "max-w-4xl",
42
+ };
43
+
44
+ function handleClose() {
45
+ open = false;
46
+ onClose?.();
47
+ }
48
+
49
+ function handleKeydown(e: KeyboardEvent) {
50
+ if (e.key === "Escape" && closable) {
51
+ handleClose();
52
+ }
53
+ }
54
+ </script>
55
+
56
+ <svelte:window onkeydown={handleKeydown} />
57
+
58
+ {#if open}
59
+ <div
60
+ use:portal
61
+ class="
62
+ fixed inset-0
63
+ z-50 flex items-center justify-center
64
+ p-4
65
+ "
66
+ >
67
+ {#if closable}
68
+ <button
69
+ type="button"
70
+ class="absolute inset-0 z-0 bg-backdrop backdrop-blur-sm"
71
+ aria-label="Close modal"
72
+ tabindex="-1"
73
+ onclick={handleClose}
74
+ ></button>
75
+ {:else}
76
+ <div class="absolute inset-0 z-0 bg-backdrop backdrop-blur-sm"></div>
77
+ {/if}
78
+
79
+ <div
80
+ class="
81
+ relative z-10 w-full {sizeClasses[size]}
82
+ flex max-h-[90vh] flex-col
83
+ rounded-2xl bg-surface-overlay shadow-xl
84
+ {className}
85
+ "
86
+ role="dialog"
87
+ aria-modal="true"
88
+ aria-labelledby={title ? "modal-title" : undefined}
89
+ >
90
+ <!-- Header -->
91
+ {#if header}
92
+ <div class="border-b border-outline-faint px-6 py-4">
93
+ {@render header()}
94
+ </div>
95
+ {:else if title || closable}
96
+ <div
97
+ class="flex items-start justify-between gap-4 border-b border-outline-faint px-6 py-4"
98
+ >
99
+ <div>
100
+ {#if title}
101
+ <h2 id="modal-title" class="text-lg font-semibold text-on-surface">
102
+ {title}
103
+ </h2>
104
+ {/if}
105
+ {#if description}
106
+ <p class="mt-1 text-sm text-on-surface-muted">{description}</p>
107
+ {/if}
108
+ </div>
109
+ {#if closable}
110
+ <button
111
+ onclick={handleClose}
112
+ class="-m-2 shrink-0 rounded-lg p-2 text-on-surface-faint transition-colors hover:bg-surface-sunken hover:text-on-surface-muted"
113
+ aria-label="Close"
114
+ >
115
+ <X size={20} />
116
+ </button>
117
+ {/if}
118
+ </div>
119
+ {/if}
120
+
121
+ <!-- Body -->
122
+ {#if children}
123
+ <div class="flex-1 overflow-y-auto px-6 py-4">
124
+ {@render children()}
125
+ </div>
126
+ {/if}
127
+
128
+ <!-- Footer -->
129
+ {#if footer}
130
+ <div
131
+ class="rounded-b-2xl border-t border-outline-faint bg-surface-sunken/50 px-6 py-4"
132
+ >
133
+ {@render footer()}
134
+ </div>
135
+ {/if}
136
+ </div>
137
+ </div>
138
+ {/if}
@@ -0,0 +1,38 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import type { Snippet } from "svelte";
6
+
7
+ /**
8
+ * PageContainer — the single, standardized page-level wrapper.
9
+ *
10
+ * Variants:
11
+ * "standard" – Max-width + centering + padding + flex-col + min-h-full.
12
+ * "full" – Same as standard but h-full (fills viewport).
13
+ * "wide" – Max-width + centering only (consumer provides padding).
14
+ * "narrow" – Narrower max-width + centering.
15
+ * "flush" – Fills container height, no max-width.
16
+ */
17
+ let {
18
+ children,
19
+ variant = "standard",
20
+ class: className = "",
21
+ }: {
22
+ children: Snippet;
23
+ variant?: "standard" | "full" | "wide" | "narrow" | "flush";
24
+ class?: string;
25
+ } = $props();
26
+
27
+ const variantClasses: Record<string, string> = {
28
+ standard: "mx-auto w-full max-w-7xl flex flex-col min-h-full p-4 md:p-8",
29
+ full: "mx-auto w-full max-w-7xl flex flex-col h-full min-h-0 p-4 md:p-8",
30
+ wide: "mx-auto w-full max-w-7xl",
31
+ narrow: "mx-auto w-full max-w-3xl",
32
+ flush: "flex flex-col h-full min-h-0",
33
+ };
34
+ </script>
35
+
36
+ <div class="animate-in fade-in duration-300 {variantClasses[variant]} {className}">
37
+ {@render children()}
38
+ </div>