@classic-homes/theme-svelte 0.1.5 → 0.1.7

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 (27) hide show
  1. package/dist/lib/components/CardFooter.svelte +20 -2
  2. package/dist/lib/components/CardFooter.svelte.d.ts +20 -2
  3. package/dist/lib/components/CardHeader.svelte +22 -2
  4. package/dist/lib/components/CardHeader.svelte.d.ts +5 -4
  5. package/dist/lib/components/HeaderSearch.svelte +340 -0
  6. package/dist/lib/components/HeaderSearch.svelte.d.ts +37 -0
  7. package/dist/lib/components/PageHeader.svelte +6 -0
  8. package/dist/lib/components/PageHeader.svelte.d.ts +1 -1
  9. package/dist/lib/components/layout/AuthLayout.svelte +133 -0
  10. package/dist/lib/components/layout/AuthLayout.svelte.d.ts +48 -0
  11. package/dist/lib/components/layout/DashboardLayout.svelte +39 -60
  12. package/dist/lib/components/layout/DashboardLayout.svelte.d.ts +6 -1
  13. package/dist/lib/components/layout/ErrorLayout.svelte +206 -0
  14. package/dist/lib/components/layout/ErrorLayout.svelte.d.ts +52 -0
  15. package/dist/lib/components/layout/Footer.svelte +58 -53
  16. package/dist/lib/components/layout/FormPageLayout.svelte +2 -8
  17. package/dist/lib/components/layout/Header.svelte +232 -41
  18. package/dist/lib/components/layout/Header.svelte.d.ts +71 -5
  19. package/dist/lib/components/layout/PublicLayout.svelte +54 -80
  20. package/dist/lib/components/layout/PublicLayout.svelte.d.ts +3 -1
  21. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +16 -7
  22. package/dist/lib/components/layout/sidebar/SidebarSection.svelte +4 -4
  23. package/dist/lib/index.d.ts +4 -1
  24. package/dist/lib/index.js +3 -0
  25. package/dist/lib/schemas/common.d.ts +2 -2
  26. package/dist/lib/types/layout.d.ts +187 -1
  27. package/package.json +1 -1
@@ -1,11 +1,29 @@
1
1
  <script lang="ts">
2
+ /**
3
+ * CardFooter - Footer section for Card component
4
+ *
5
+ * Provides consistent padding and flex layout for card action buttons
6
+ * or summary content.
7
+ *
8
+ * @example
9
+ * <Card>
10
+ * <CardHeader>...</CardHeader>
11
+ * <CardContent>...</CardContent>
12
+ * <CardFooter>
13
+ * <Button>Save</Button>
14
+ * <Button variant="outline">Cancel</Button>
15
+ * </CardFooter>
16
+ * </Card>
17
+ */
2
18
  import type { Snippet } from 'svelte';
19
+ import type { HTMLAttributes } from 'svelte/elements';
3
20
  import { cn } from '../utils.js';
4
21
 
5
- interface Props {
22
+ interface Props extends HTMLAttributes<HTMLDivElement> {
23
+ /** Additional CSS classes */
6
24
  class?: string;
25
+ /** Footer content */
7
26
  children: Snippet;
8
- [key: string]: unknown;
9
27
  }
10
28
 
11
29
  let { class: className, children, ...restProps }: Props = $props();
@@ -1,8 +1,26 @@
1
+ /**
2
+ * CardFooter - Footer section for Card component
3
+ *
4
+ * Provides consistent padding and flex layout for card action buttons
5
+ * or summary content.
6
+ *
7
+ * @example
8
+ * <Card>
9
+ * <CardHeader>...</CardHeader>
10
+ * <CardContent>...</CardContent>
11
+ * <CardFooter>
12
+ * <Button>Save</Button>
13
+ * <Button variant="outline">Cancel</Button>
14
+ * </CardFooter>
15
+ * </Card>
16
+ */
1
17
  import type { Snippet } from 'svelte';
2
- interface Props {
18
+ import type { HTMLAttributes } from 'svelte/elements';
19
+ interface Props extends HTMLAttributes<HTMLDivElement> {
20
+ /** Additional CSS classes */
3
21
  class?: string;
22
+ /** Footer content */
4
23
  children: Snippet;
5
- [key: string]: unknown;
6
24
  }
7
25
  declare const CardFooter: import("svelte").Component<Props, {}, "">;
8
26
  type CardFooter = ReturnType<typeof CardFooter>;
@@ -1,16 +1,36 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import { cn } from '../utils.js';
4
+ import { tv, type VariantProps } from 'tailwind-variants';
5
+
6
+ const cardHeaderVariants = tv({
7
+ base: 'flex flex-col space-y-1.5',
8
+ variants: {
9
+ variant: {
10
+ default: 'p-6',
11
+ compact: 'p-4',
12
+ bordered: 'p-6 border-b',
13
+ shaded: 'p-6 bg-muted/50',
14
+ },
15
+ },
16
+ defaultVariants: {
17
+ variant: 'default',
18
+ },
19
+ });
20
+
21
+ type CardHeaderVariants = VariantProps<typeof cardHeaderVariants>;
4
22
 
5
23
  interface Props {
24
+ /** Visual variant */
25
+ variant?: CardHeaderVariants['variant'];
6
26
  class?: string;
7
27
  children: Snippet;
8
28
  [key: string]: unknown;
9
29
  }
10
30
 
11
- let { class: className, children, ...restProps }: Props = $props();
31
+ let { variant = 'default', class: className, children, ...restProps }: Props = $props();
12
32
  </script>
13
33
 
14
- <div class={cn('flex flex-col space-y-1.5 p-6', className)} {...restProps}>
34
+ <div class={cn(cardHeaderVariants({ variant }), className)} {...restProps}>
15
35
  {@render children()}
16
36
  </div>
@@ -1,9 +1,10 @@
1
1
  import type { Snippet } from 'svelte';
2
- interface Props {
2
+ declare const CardHeader: import("svelte").Component<{
3
+ [key: string]: unknown;
4
+ /** Visual variant */
5
+ variant?: "default" | "compact" | "bordered" | "shaded" | undefined;
3
6
  class?: string;
4
7
  children: Snippet;
5
- [key: string]: unknown;
6
- }
7
- declare const CardHeader: import("svelte").Component<Props, {}, "">;
8
+ }, {}, "">;
8
9
  type CardHeader = ReturnType<typeof CardHeader>;
9
10
  export default CardHeader;
@@ -0,0 +1,340 @@
1
+ <script lang="ts">
2
+ /**
3
+ * HeaderSearch - Docusaurus-style search component for the header
4
+ *
5
+ * A flexible, configurable search component that displays a trigger button
6
+ * in the header and opens a modal dialog with search input and results.
7
+ *
8
+ * Features:
9
+ * - Backend-agnostic (callback-based integration)
10
+ * - Keyboard shortcut support (Cmd/Ctrl+K)
11
+ * - Full keyboard navigation in results
12
+ * - Custom result rendering via snippets
13
+ * - Loading and empty states
14
+ *
15
+ * @example Basic usage:
16
+ * ```svelte
17
+ * <HeaderSearch
18
+ * enabled={true}
19
+ * placeholder="Search..."
20
+ * onSearch={(query) => handleSearch(query)}
21
+ * onSelect={(item) => goto(item.href)}
22
+ * results={searchResults}
23
+ * loading={isSearching}
24
+ * />
25
+ * ```
26
+ */
27
+ import { Dialog as DialogPrimitive } from 'bits-ui';
28
+ import { cn } from '../utils.js';
29
+ import type { Snippet } from 'svelte';
30
+ import type { SearchResultItem, SearchResultGroup } from '../types/layout.js';
31
+ import Spinner from './Spinner.svelte';
32
+
33
+ interface Props<T = SearchResultItem> {
34
+ /** Whether search is enabled/visible */
35
+ enabled?: boolean;
36
+ /** Placeholder text for search input */
37
+ placeholder?: string;
38
+ /** Called when user types (debounced). App should update results prop. */
39
+ onSearch?: (query: string) => void;
40
+ /** Called when user selects a result */
41
+ onSelect?: (item: T) => void;
42
+ /** Called when search dialog opens/closes */
43
+ onOpenChange?: (open: boolean) => void;
44
+ /** Search results to display (flat list or grouped) */
45
+ results?: T[] | SearchResultGroup<T>[];
46
+ /** Whether results are currently loading */
47
+ loading?: boolean;
48
+ /** Message when no results found */
49
+ emptyMessage?: string;
50
+ /** Enable Cmd/Ctrl+K shortcut (default: true) */
51
+ enableShortcut?: boolean;
52
+ /** Custom shortcut key (default: 'k') */
53
+ shortcutKey?: string;
54
+ /** Trigger button variant: 'default' shows full search box, 'icon' shows icon-only button */
55
+ variant?: 'default' | 'icon';
56
+ /** Size variant for trigger button */
57
+ size?: 'sm' | 'md';
58
+ /** Custom result item renderer - receives (item, isSelected) */
59
+ renderResult?: Snippet<[T, boolean]>;
60
+ /** Custom empty state renderer */
61
+ renderEmpty?: Snippet;
62
+ /** Additional classes for trigger button */
63
+ class?: string;
64
+ }
65
+
66
+ let {
67
+ enabled = true,
68
+ placeholder = 'Search...',
69
+ onSearch,
70
+ onSelect,
71
+ onOpenChange,
72
+ results = [],
73
+ loading = false,
74
+ emptyMessage = 'No results found.',
75
+ enableShortcut = true,
76
+ shortcutKey = 'k',
77
+ variant = 'default',
78
+ size = 'md',
79
+ renderResult,
80
+ renderEmpty,
81
+ class: className,
82
+ }: Props = $props();
83
+
84
+ // State
85
+ let open = $state(false);
86
+ let query = $state('');
87
+ let selectedIndex = $state(0);
88
+ let inputRef: HTMLInputElement | undefined = $state();
89
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
90
+
91
+ // Flatten results for keyboard navigation
92
+ const flatResults = $derived.by(() => {
93
+ if (!results || results.length === 0) return [];
94
+ // Check if grouped results
95
+ if (results.length > 0 && 'items' in results[0]) {
96
+ return (results as SearchResultGroup[]).flatMap((g) => g.items);
97
+ }
98
+ return results as SearchResultItem[];
99
+ });
100
+
101
+ // Keyboard shortcut handler
102
+ $effect(() => {
103
+ if (!enableShortcut || typeof window === 'undefined') return;
104
+
105
+ const handleKeydown = (e: KeyboardEvent) => {
106
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === shortcutKey) {
107
+ e.preventDefault();
108
+ open = true;
109
+ }
110
+ };
111
+
112
+ window.addEventListener('keydown', handleKeydown);
113
+ return () => window.removeEventListener('keydown', handleKeydown);
114
+ });
115
+
116
+ // Focus input when dialog opens
117
+ $effect(() => {
118
+ if (open && inputRef) {
119
+ requestAnimationFrame(() => inputRef?.focus());
120
+ }
121
+ });
122
+
123
+ // Debounced search
124
+ function handleInput(value: string) {
125
+ query = value;
126
+ selectedIndex = 0;
127
+
128
+ if (debounceTimer) clearTimeout(debounceTimer);
129
+ debounceTimer = setTimeout(() => {
130
+ onSearch?.(value);
131
+ }, 200);
132
+ }
133
+
134
+ // Keyboard navigation within results
135
+ function handleKeydown(e: KeyboardEvent) {
136
+ switch (e.key) {
137
+ case 'ArrowDown':
138
+ e.preventDefault();
139
+ selectedIndex = Math.min(selectedIndex + 1, flatResults.length - 1);
140
+ break;
141
+ case 'ArrowUp':
142
+ e.preventDefault();
143
+ selectedIndex = Math.max(selectedIndex - 1, 0);
144
+ break;
145
+ case 'Enter':
146
+ e.preventDefault();
147
+ if (flatResults[selectedIndex]) {
148
+ handleSelect(flatResults[selectedIndex]);
149
+ }
150
+ break;
151
+ }
152
+ }
153
+
154
+ function handleSelect(item: SearchResultItem) {
155
+ onSelect?.(item);
156
+ open = false;
157
+ query = '';
158
+ }
159
+
160
+ function handleOpenChange(isOpen: boolean) {
161
+ open = isOpen;
162
+ onOpenChange?.(isOpen);
163
+ if (!isOpen) {
164
+ query = '';
165
+ selectedIndex = 0;
166
+ }
167
+ }
168
+ </script>
169
+
170
+ {#if enabled}
171
+ <!-- Trigger Button -->
172
+ {#if variant === 'icon'}
173
+ <!-- Icon-only variant -->
174
+ <button
175
+ type="button"
176
+ onclick={() => (open = true)}
177
+ class={cn(
178
+ 'inline-flex items-center justify-center rounded-md border border-input bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
179
+ size === 'sm' ? 'h-8 w-8' : 'h-9 w-9',
180
+ className
181
+ )}
182
+ aria-label="Search"
183
+ title={enableShortcut ? `Search (⌘/${shortcutKey.toUpperCase()})` : 'Search'}
184
+ >
185
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
186
+ <path
187
+ stroke-linecap="round"
188
+ stroke-linejoin="round"
189
+ stroke-width="2"
190
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
191
+ />
192
+ </svg>
193
+ </button>
194
+ {:else}
195
+ <!-- Default full search box variant -->
196
+ <button
197
+ type="button"
198
+ onclick={() => (open = true)}
199
+ class={cn(
200
+ 'flex items-center gap-2 rounded-md border border-input bg-background px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
201
+ size === 'sm' ? 'h-8' : 'h-9',
202
+ className
203
+ )}
204
+ aria-label="Search"
205
+ >
206
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
207
+ <path
208
+ stroke-linecap="round"
209
+ stroke-linejoin="round"
210
+ stroke-width="2"
211
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
212
+ />
213
+ </svg>
214
+ <span class="hidden sm:inline">{placeholder}</span>
215
+ {#if enableShortcut}
216
+ <kbd
217
+ class="pointer-events-none hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground sm:inline-flex"
218
+ >
219
+ <span class="text-xs">⌘</span>{shortcutKey.toUpperCase()}
220
+ </kbd>
221
+ {/if}
222
+ </button>
223
+ {/if}
224
+
225
+ <!-- Search Dialog -->
226
+ <DialogPrimitive.Root bind:open onOpenChange={handleOpenChange}>
227
+ <DialogPrimitive.Portal>
228
+ <DialogPrimitive.Overlay
229
+ class="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
230
+ />
231
+ <DialogPrimitive.Content
232
+ class="fixed left-[50%] top-[20%] z-50 w-full max-w-lg translate-x-[-50%] overflow-hidden rounded-lg border bg-background shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:top-[15%]"
233
+ aria-describedby={undefined}
234
+ >
235
+ <!-- Search Input -->
236
+ <div class="flex items-center border-b px-3">
237
+ <svg
238
+ class="h-4 w-4 shrink-0 text-muted-foreground"
239
+ fill="none"
240
+ viewBox="0 0 24 24"
241
+ stroke="currentColor"
242
+ >
243
+ <path
244
+ stroke-linecap="round"
245
+ stroke-linejoin="round"
246
+ stroke-width="2"
247
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
248
+ />
249
+ </svg>
250
+ <input
251
+ bind:this={inputRef}
252
+ type="search"
253
+ {placeholder}
254
+ value={query}
255
+ oninput={(e) => handleInput(e.currentTarget.value)}
256
+ onkeydown={handleKeydown}
257
+ class="flex h-12 w-full bg-transparent px-3 py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
258
+ autocomplete="off"
259
+ autocorrect="off"
260
+ spellcheck="false"
261
+ />
262
+ {#if loading}
263
+ <Spinner size="sm" class="shrink-0" />
264
+ {/if}
265
+ </div>
266
+
267
+ <!-- Results Area -->
268
+ <div class="max-h-[300px] overflow-y-auto p-2" role="listbox">
269
+ {#if loading && flatResults.length === 0}
270
+ <div class="flex items-center justify-center py-6">
271
+ <Spinner size="sm" />
272
+ <span class="ml-2 text-sm text-muted-foreground">Searching...</span>
273
+ </div>
274
+ {:else if flatResults.length === 0 && query}
275
+ {#if renderEmpty}
276
+ {@render renderEmpty()}
277
+ {:else}
278
+ <div class="py-6 text-center text-sm text-muted-foreground">
279
+ {emptyMessage}
280
+ </div>
281
+ {/if}
282
+ {:else if flatResults.length === 0}
283
+ <div class="py-6 text-center text-sm text-muted-foreground">Type to search...</div>
284
+ {:else}
285
+ {#each flatResults as item, index (item.id)}
286
+ <button
287
+ type="button"
288
+ onclick={() => handleSelect(item)}
289
+ class={cn(
290
+ 'flex w-full cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors',
291
+ index === selectedIndex
292
+ ? 'bg-accent text-accent-foreground'
293
+ : 'hover:bg-accent/50'
294
+ )}
295
+ role="option"
296
+ aria-selected={index === selectedIndex}
297
+ >
298
+ {#if renderResult}
299
+ {@render renderResult(item, index === selectedIndex)}
300
+ {:else}
301
+ <div class="min-w-0 flex-1">
302
+ <div class="truncate font-medium">{item.title}</div>
303
+ {#if item.description}
304
+ <div class="truncate text-xs text-muted-foreground">
305
+ {item.description}
306
+ </div>
307
+ {/if}
308
+ </div>
309
+ {#if item.category}
310
+ <span class="shrink-0 text-xs text-muted-foreground">
311
+ {item.category}
312
+ </span>
313
+ {/if}
314
+ {/if}
315
+ </button>
316
+ {/each}
317
+ {/if}
318
+ </div>
319
+
320
+ <!-- Footer with keyboard hints -->
321
+ <div
322
+ class="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground"
323
+ >
324
+ <div class="flex items-center gap-1">
325
+ <kbd class="rounded border bg-muted px-1.5 py-0.5">↑↓</kbd>
326
+ <span>Navigate</span>
327
+ </div>
328
+ <div class="flex items-center gap-1">
329
+ <kbd class="rounded border bg-muted px-1.5 py-0.5">↵</kbd>
330
+ <span>Select</span>
331
+ </div>
332
+ <div class="flex items-center gap-1">
333
+ <kbd class="rounded border bg-muted px-1.5 py-0.5">esc</kbd>
334
+ <span>Close</span>
335
+ </div>
336
+ </div>
337
+ </DialogPrimitive.Content>
338
+ </DialogPrimitive.Portal>
339
+ </DialogPrimitive.Root>
340
+ {/if}
@@ -0,0 +1,37 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { SearchResultItem, SearchResultGroup } from '../types/layout.js';
3
+ interface Props<T = SearchResultItem> {
4
+ /** Whether search is enabled/visible */
5
+ enabled?: boolean;
6
+ /** Placeholder text for search input */
7
+ placeholder?: string;
8
+ /** Called when user types (debounced). App should update results prop. */
9
+ onSearch?: (query: string) => void;
10
+ /** Called when user selects a result */
11
+ onSelect?: (item: T) => void;
12
+ /** Called when search dialog opens/closes */
13
+ onOpenChange?: (open: boolean) => void;
14
+ /** Search results to display (flat list or grouped) */
15
+ results?: T[] | SearchResultGroup<T>[];
16
+ /** Whether results are currently loading */
17
+ loading?: boolean;
18
+ /** Message when no results found */
19
+ emptyMessage?: string;
20
+ /** Enable Cmd/Ctrl+K shortcut (default: true) */
21
+ enableShortcut?: boolean;
22
+ /** Custom shortcut key (default: 'k') */
23
+ shortcutKey?: string;
24
+ /** Trigger button variant: 'default' shows full search box, 'icon' shows icon-only button */
25
+ variant?: 'default' | 'icon';
26
+ /** Size variant for trigger button */
27
+ size?: 'sm' | 'md';
28
+ /** Custom result item renderer - receives (item, isSelected) */
29
+ renderResult?: Snippet<[T, boolean]>;
30
+ /** Custom empty state renderer */
31
+ renderEmpty?: Snippet;
32
+ /** Additional classes for trigger button */
33
+ class?: string;
34
+ }
35
+ declare const HeaderSearch: import("svelte").Component<Props<SearchResultItem>, {}, "">;
36
+ type HeaderSearch = ReturnType<typeof HeaderSearch>;
37
+ export default HeaderSearch;
@@ -42,6 +42,12 @@
42
42
  subtitle: 'text-xl max-w-2xl mx-auto',
43
43
  actionsWrapper: 'mt-6 justify-center',
44
44
  },
45
+ form: {
46
+ container: 'text-center mb-8',
47
+ title: 'text-3xl md:text-4xl font-light tracking-tight text-brand-core-2',
48
+ subtitle: 'mt-3 text-base md:text-lg text-gray-600',
49
+ actionsWrapper: 'mt-6 justify-center',
50
+ },
45
51
  },
46
52
  },
47
53
  defaultVariants: {
@@ -18,7 +18,7 @@ declare const PageHeader: import("svelte").Component<{
18
18
  /** Optional subtitle text */
19
19
  subtitle?: string;
20
20
  /** Visual variant */
21
- variant?: "default" | "hero" | "centered" | undefined;
21
+ variant?: "default" | "form" | "hero" | "centered" | undefined;
22
22
  /** Additional classes for the container */
23
23
  class?: string;
24
24
  /** Optional action buttons/content */
@@ -0,0 +1,133 @@
1
+ <script lang="ts">
2
+ /**
3
+ * AuthLayout - Layout for authentication pages
4
+ *
5
+ * Features:
6
+ * - Centered card layout for auth forms
7
+ * - Logo with optional subtitle
8
+ * - Footer links (privacy, terms, etc.)
9
+ * - Optional background decoration
10
+ * - Responsive design
11
+ * - Composes AppShell for consistent base structure
12
+ *
13
+ * Use this layout for: login, signup, password reset, forgot password,
14
+ * email verification, 2FA, and other authentication flows.
15
+ */
16
+ import type { Snippet } from 'svelte';
17
+ import { cn } from '../../utils.js';
18
+ import AppShell from './AppShell.svelte';
19
+ import LogoMain from '../LogoMain.svelte';
20
+
21
+ interface FooterLink {
22
+ /** Link label */
23
+ label: string;
24
+ /** Link URL */
25
+ href: string;
26
+ /** Opens in new tab */
27
+ external?: boolean;
28
+ }
29
+
30
+ interface Props {
31
+ /** Custom logo snippet */
32
+ logo?: Snippet;
33
+ /** Logo subtitle for default logo (e.g., "Sign in to your account") */
34
+ logoSubtitle?: string;
35
+ /** Logo environment indicator for default logo */
36
+ logoEnvironment?: 'local' | 'dev' | 'demo';
37
+ /** Footer links (privacy policy, terms, etc.) */
38
+ footerLinks?: FooterLink[];
39
+ /** Custom footer content (replaces footer links) */
40
+ footer?: Snippet;
41
+ /** Show decorative background */
42
+ showBackground?: boolean;
43
+ /** Background variant */
44
+ backgroundVariant?: 'default' | 'gradient' | 'pattern';
45
+ /** Maximum width of content card */
46
+ maxWidth?: 'sm' | 'md' | 'lg';
47
+ /** Additional classes for the container */
48
+ class?: string;
49
+ /** Main content (auth form) */
50
+ children: Snippet;
51
+ }
52
+
53
+ let {
54
+ logo,
55
+ logoSubtitle,
56
+ logoEnvironment,
57
+ footerLinks = [],
58
+ footer,
59
+ showBackground = false,
60
+ backgroundVariant = 'default',
61
+ maxWidth = 'sm',
62
+ class: className,
63
+ children,
64
+ }: Props = $props();
65
+
66
+ const maxWidthClasses = {
67
+ sm: 'max-w-sm',
68
+ md: 'max-w-md',
69
+ lg: 'max-w-lg',
70
+ };
71
+
72
+ const backgroundClasses = {
73
+ default: 'bg-content-bg',
74
+ gradient: 'bg-gradient-to-br from-primary/5 via-content-bg to-accent/5',
75
+ pattern:
76
+ 'bg-content-bg bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-content-bg to-content-bg',
77
+ };
78
+ </script>
79
+
80
+ <AppShell>
81
+ <div
82
+ class={cn(
83
+ 'flex h-screen flex-col items-center justify-center overflow-auto bg-content-bg px-4 py-8 sm:px-6 lg:px-8',
84
+ showBackground && backgroundClasses[backgroundVariant],
85
+ className
86
+ )}
87
+ >
88
+ <!-- Logo -->
89
+ <div class="mb-6 flex flex-col items-center">
90
+ {#if logo}
91
+ {@render logo()}
92
+ {:else}
93
+ <LogoMain
94
+ variant="stacked"
95
+ color="dark"
96
+ size="lg"
97
+ subtitle={logoSubtitle}
98
+ environment={logoEnvironment}
99
+ />
100
+ {/if}
101
+ </div>
102
+
103
+ <!-- Main Content Card -->
104
+ <main
105
+ id="main-content"
106
+ class={cn('w-full rounded-lg bg-background p-6 shadow-lg sm:p-8', maxWidthClasses[maxWidth])}
107
+ >
108
+ {@render children()}
109
+ </main>
110
+
111
+ <!-- Footer Links -->
112
+ {#if footer}
113
+ <footer class="mt-6">
114
+ {@render footer()}
115
+ </footer>
116
+ {:else if footerLinks.length > 0}
117
+ <footer class="mt-6">
118
+ <nav class="flex flex-wrap justify-center gap-x-6 gap-y-2" aria-label="Footer">
119
+ {#each footerLinks as link}
120
+ <a
121
+ href={link.href}
122
+ class="text-sm text-muted-foreground transition-colors hover:text-foreground"
123
+ target={link.external ? '_blank' : undefined}
124
+ rel={link.external ? 'noopener noreferrer' : undefined}
125
+ >
126
+ {link.label}
127
+ </a>
128
+ {/each}
129
+ </nav>
130
+ </footer>
131
+ {/if}
132
+ </div>
133
+ </AppShell>