@classic-homes/theme-svelte 0.1.6 → 0.1.8

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.
@@ -1,7 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  declare const Alert: import("svelte").Component<{
3
3
  [key: string]: unknown;
4
- variant?: "default" | "destructive" | "error" | "success" | "warning" | "info" | undefined;
4
+ variant?: "default" | "destructive" | "warning" | "error" | "success" | "info" | undefined;
5
5
  class?: string;
6
6
  children: Snippet;
7
7
  }, {}, "">;
@@ -1,7 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  declare const Badge: import("svelte").Component<{
3
3
  [key: string]: unknown;
4
- variant?: "default" | "secondary" | "destructive" | "outline" | "success" | "warning" | "info" | undefined;
4
+ variant?: "default" | "secondary" | "destructive" | "outline" | "warning" | "success" | "info" | undefined;
5
5
  size?: "default" | "sm" | "dot" | undefined;
6
6
  clickable?: boolean;
7
7
  class?: string;
@@ -5,8 +5,14 @@
5
5
  * Features:
6
6
  * - Clean border styling with sharp look
7
7
  * - Optional hover shadow effect for interactive cards
8
- * - Featured variant with thicker border (border-2)
8
+ * - Multiple variants: default, featured, highlight, warning
9
9
  * - Consistent with brand guidelines
10
+ *
11
+ * Variants:
12
+ * - default: Standard card with 1px border
13
+ * - featured: Thicker 2px border for emphasis
14
+ * - highlight: Teal border (brand-core-1) for positive/info callouts
15
+ * - warning: Red border (brand-red-primary) for warnings/alerts
10
16
  */
11
17
  import type { Snippet } from 'svelte';
12
18
  import { cn } from '../utils.js';
@@ -14,9 +20,15 @@
14
20
  interface Props {
15
21
  /** Enable hover shadow effect for interactive cards */
16
22
  interactive?: boolean;
17
- /** Card variant - default has 1px border, featured has 2px border */
18
- variant?: 'default' | 'featured';
19
- /** Border color for featured variant */
23
+ /**
24
+ * Card variant
25
+ * - default: Standard 1px border
26
+ * - featured: Thicker 2px border
27
+ * - highlight: Teal border for positive/info callouts
28
+ * - warning: Red border for warnings/alerts
29
+ */
30
+ variant?: 'default' | 'featured' | 'highlight' | 'warning';
31
+ /** Border color override (ignored for highlight/warning variants) */
20
32
  borderColor?: 'default' | 'primary' | 'muted';
21
33
  /** Additional classes */
22
34
  class?: string;
@@ -39,15 +51,26 @@
39
51
  primary: 'border-primary',
40
52
  muted: 'border-gray-200',
41
53
  };
54
+
55
+ // Variant-specific border colors (highlight and warning override borderColor)
56
+ const variantBorderClasses = $derived({
57
+ default: borderColorClasses[borderColor],
58
+ featured: borderColorClasses[borderColor],
59
+ highlight: 'border-brand-core-1',
60
+ warning: 'border-brand-red-primary',
61
+ });
62
+
63
+ // Variants with thick borders
64
+ const thickBorderVariants = ['featured', 'highlight', 'warning'];
42
65
  </script>
43
66
 
44
67
  <div
45
68
  class={cn(
46
69
  'rounded-md bg-card text-card-foreground',
47
- // Border thickness based on variant
48
- variant === 'featured' ? 'border-2' : 'border',
49
- // Border color
50
- borderColorClasses[borderColor],
70
+ // Border thickness - thick for featured, highlight, warning
71
+ thickBorderVariants.includes(variant) ? 'border-2' : 'border',
72
+ // Border color based on variant
73
+ variantBorderClasses[variant],
51
74
  // Interactive hover effect
52
75
  interactive && 'hover:shadow-lg transition-shadow duration-200',
53
76
  className
@@ -4,16 +4,28 @@
4
4
  * Features:
5
5
  * - Clean border styling with sharp look
6
6
  * - Optional hover shadow effect for interactive cards
7
- * - Featured variant with thicker border (border-2)
7
+ * - Multiple variants: default, featured, highlight, warning
8
8
  * - Consistent with brand guidelines
9
+ *
10
+ * Variants:
11
+ * - default: Standard card with 1px border
12
+ * - featured: Thicker 2px border for emphasis
13
+ * - highlight: Teal border (brand-core-1) for positive/info callouts
14
+ * - warning: Red border (brand-red-primary) for warnings/alerts
9
15
  */
10
16
  import type { Snippet } from 'svelte';
11
17
  interface Props {
12
18
  /** Enable hover shadow effect for interactive cards */
13
19
  interactive?: boolean;
14
- /** Card variant - default has 1px border, featured has 2px border */
15
- variant?: 'default' | 'featured';
16
- /** Border color for featured variant */
20
+ /**
21
+ * Card variant
22
+ * - default: Standard 1px border
23
+ * - featured: Thicker 2px border
24
+ * - highlight: Teal border for positive/info callouts
25
+ * - warning: Red border for warnings/alerts
26
+ */
27
+ variant?: 'default' | 'featured' | 'highlight' | 'warning';
28
+ /** Border color override (ignored for highlight/warning variants) */
17
29
  borderColor?: 'default' | 'primary' | 'muted';
18
30
  /** Additional classes */
19
31
  class?: string;
@@ -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,7 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  declare const Toast: import("svelte").Component<{
3
3
  [key: string]: unknown;
4
- type?: "error" | "success" | "warning" | "info" | undefined;
4
+ type?: "warning" | "error" | "success" | "info" | undefined;
5
5
  title?: string;
6
6
  message: string;
7
7
  class?: string;
@@ -42,72 +42,77 @@
42
42
  class: className,
43
43
  }: Props = $props();
44
44
 
45
- const currentYear = new Date().getFullYear();
46
- const defaultCopyright = `${currentYear} Classic Homes. All rights reserved.`;
47
-
48
45
  const isDark = $derived(variant === 'dark');
46
+ const displayCopyright = $derived(
47
+ copyright || `${new Date().getFullYear()} Classic Homes. All rights reserved.`
48
+ );
49
49
  </script>
50
50
 
51
51
  <footer
52
+ aria-label="Site footer"
52
53
  class={cn(
53
54
  // Base styles
54
- isDark ? 'bg-[#50504f] text-white' : 'bg-muted/30',
55
+ isDark ? 'bg-gray-600 text-white' : 'bg-muted/30',
55
56
  // Border styles - strong accent border or standard
56
- strongBorder ? 'border-t-[10px] border-[#787878]' : 'border-t border-black',
57
+ strongBorder ? 'border-t-[10px] border-gray-500' : 'border-t border-border',
57
58
  className
58
59
  )}
59
60
  >
60
61
  <div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
61
62
  {#if links.length > 0}
62
- <div class="grid grid-cols-2 gap-8 md:grid-cols-4">
63
- {#each links as section}
64
- <div>
65
- {#if section.title}
66
- <h3 class={cn('text-lg font-bold mb-4', isDark ? 'text-white' : 'text-foreground')}>
67
- {section.title}
68
- </h3>
69
- {/if}
70
- <ul class="mt-4 space-y-2">
71
- {#each section.items as item}
72
- <li>
73
- <a
74
- href={item.href}
75
- class={cn(
76
- 'text-sm transition-colors',
77
- isDark
78
- ? 'text-white hover:text-primary'
79
- : 'text-muted-foreground hover:text-foreground'
80
- )}
81
- target={item.external ? '_blank' : undefined}
82
- rel={item.external ? 'noopener noreferrer' : undefined}
83
- >
84
- {item.name}
85
- {#if item.external}
86
- <svg
87
- class="ml-1 inline-block h-3 w-3"
88
- fill="none"
89
- viewBox="0 0 24 24"
90
- stroke="currentColor"
91
- >
92
- <path
93
- stroke-linecap="round"
94
- stroke-linejoin="round"
95
- stroke-width="2"
96
- d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
97
- />
98
- </svg>
99
- {/if}
100
- </a>
101
- </li>
102
- {/each}
103
- </ul>
104
- </div>
105
- {/each}
106
- </div>
63
+ <nav aria-label="Footer navigation">
64
+ <div class="grid grid-cols-2 gap-6 sm:grid-cols-3 md:gap-8 lg:grid-cols-4">
65
+ {#each links as section}
66
+ <div>
67
+ {#if section.title}
68
+ <h3 class={cn('text-lg font-bold mb-4', isDark ? 'text-white' : 'text-foreground')}>
69
+ {section.title}
70
+ </h3>
71
+ {/if}
72
+ <ul class="mt-4 space-y-2">
73
+ {#each section.items as item}
74
+ <li>
75
+ <a
76
+ href={item.href}
77
+ class={cn(
78
+ 'text-sm transition-colors',
79
+ isDark
80
+ ? 'text-white hover:text-primary'
81
+ : 'text-muted-foreground hover:text-foreground'
82
+ )}
83
+ target={item.external ? '_blank' : undefined}
84
+ rel={item.external ? 'noopener noreferrer' : undefined}
85
+ >
86
+ {item.name}
87
+ {#if item.external}
88
+ <span class="sr-only">(opens in new tab)</span>
89
+ <svg
90
+ aria-hidden="true"
91
+ class="ml-1 inline-block h-3 w-3"
92
+ fill="none"
93
+ viewBox="0 0 24 24"
94
+ stroke="currentColor"
95
+ >
96
+ <path
97
+ stroke-linecap="round"
98
+ stroke-linejoin="round"
99
+ stroke-width="2"
100
+ d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
101
+ />
102
+ </svg>
103
+ {/if}
104
+ </a>
105
+ </li>
106
+ {/each}
107
+ </ul>
108
+ </div>
109
+ {/each}
110
+ </div>
111
+ </nav>
107
112
  {/if}
108
113
 
109
114
  {#if children}
110
- <div class={cn('mt-8 border-t pt-8', isDark ? 'border-gray-600' : 'border-black')}>
115
+ <div class={cn('mt-8 border-t pt-8', isDark ? 'border-gray-500' : 'border-border')}>
111
116
  {@render children()}
112
117
  </div>
113
118
  {/if}
@@ -115,7 +120,7 @@
115
120
  <div
116
121
  class={cn(
117
122
  'mt-8 flex flex-col items-center justify-between gap-4 border-t pt-8 md:flex-row',
118
- isDark ? 'border-gray-600' : 'border-black'
123
+ isDark ? 'border-gray-500' : 'border-border'
119
124
  )}
120
125
  >
121
126
  {#if showLogo}
@@ -123,7 +128,7 @@
123
128
  {/if}
124
129
 
125
130
  <p class={cn('text-center text-sm', isDark ? 'text-gray-400' : 'text-muted-foreground')}>
126
- {copyright || defaultCopyright}
131
+ {displayCopyright}
127
132
  </p>
128
133
  </div>
129
134
  </div>
@@ -6,3 +6,4 @@
6
6
  */
7
7
  export { useForm, type UseFormOptions, type UseFormReturn, type FieldError, type FormState, type InferFormData, } from './useForm.svelte.js';
8
8
  export { useAsync, runAsync, type UseAsyncOptions, type UseAsyncReturn, } from './useAsync.svelte.js';
9
+ export { usePersistedForm, type UsePersistedFormOptions, type UsePersistedFormReturn, type InferPersistedFormData, } from './usePersistedForm.svelte.js';
@@ -8,3 +8,5 @@
8
8
  export { useForm, } from './useForm.svelte.js';
9
9
  // Async operation composable
10
10
  export { useAsync, runAsync, } from './useAsync.svelte.js';
11
+ // Persisted form composable with localStorage draft saving
12
+ export { usePersistedForm, } from './usePersistedForm.svelte.js';
@@ -0,0 +1,126 @@
1
+ /**
2
+ * usePersistedForm - Form state management with localStorage persistence
3
+ *
4
+ * Extends useForm with:
5
+ * - Automatic draft saving to localStorage with debounce
6
+ * - Draft restoration on mount
7
+ * - Configurable expiration time
8
+ * - Field exclusion from persistence (e.g., consent fields)
9
+ * - Duplicate submission prevention
10
+ *
11
+ * @example
12
+ * ```svelte
13
+ * <script lang="ts">
14
+ * import { usePersistedForm } from '@classic-homes/theme-svelte';
15
+ * import { z } from 'zod';
16
+ *
17
+ * const schema = z.object({
18
+ * email: z.string().email(),
19
+ * name: z.string().min(1),
20
+ * consent: z.boolean(),
21
+ * });
22
+ *
23
+ * const form = usePersistedForm({
24
+ * schema,
25
+ * initialValues: { email: '', name: '', consent: false },
26
+ * onSubmit: async (data) => {
27
+ * await submitForm(data);
28
+ * },
29
+ * storageKey: 'my-form-draft',
30
+ * excludeFields: ['consent'], // Don't persist consent
31
+ * expirationMs: 7 * 24 * 60 * 60 * 1000, // 7 days
32
+ * onDraftRestored: () => {
33
+ * console.log('Draft was restored');
34
+ * },
35
+ * });
36
+ * </script>
37
+ *
38
+ * <form onsubmit={form.handleSubmit}>
39
+ * <input bind:value={form.data.email} />
40
+ * {#if form.draftRestored}
41
+ * <div>Your previous draft has been restored.</div>
42
+ * {/if}
43
+ * </form>
44
+ * ```
45
+ */
46
+ import { z } from 'zod';
47
+ export interface UsePersistedFormOptions<T extends z.ZodObject<z.ZodRawShape>> {
48
+ /** Zod schema for validation */
49
+ schema: T;
50
+ /** Initial form values */
51
+ initialValues: z.infer<T>;
52
+ /** Called on successful validation and submission */
53
+ onSubmit: (data: z.infer<T>) => Promise<void> | void;
54
+ /** localStorage key for draft persistence */
55
+ storageKey: string;
56
+ /** Called when validation or submission fails */
57
+ onError?: (error: Error | z.ZodError) => void;
58
+ /** Whether to show toast on error (default: true) */
59
+ showToastOnError?: boolean;
60
+ /** Whether to reset form after successful submission (default: false) */
61
+ resetOnSuccess?: boolean;
62
+ /** Custom error message for toast */
63
+ errorMessage?: string;
64
+ /** Custom success message for toast (if set, shows toast on success) */
65
+ successMessage?: string;
66
+ /** Fields to exclude from persistence (e.g., consent fields that require re-confirmation) */
67
+ excludeFields?: (keyof z.infer<T>)[];
68
+ /** Expiration time in milliseconds (default: 7 days) */
69
+ expirationMs?: number;
70
+ /** Callback when a draft is restored */
71
+ onDraftRestored?: () => void;
72
+ /** Debounce time for saving drafts in milliseconds (default: 500ms) */
73
+ debounceMs?: number;
74
+ /** Cooldown time between submissions in milliseconds (default: 5000ms) */
75
+ submitCooldownMs?: number;
76
+ }
77
+ export interface UsePersistedFormReturn<T extends z.ZodObject<z.ZodRawShape>> {
78
+ /** Current form data (reactive) */
79
+ readonly data: z.infer<T>;
80
+ /** Field errors (reactive) */
81
+ readonly errors: Record<string, string>;
82
+ /** Whether form is submitting (reactive) */
83
+ readonly isSubmitting: boolean;
84
+ /** Whether form has been submitted (reactive) */
85
+ readonly isSubmitted: boolean;
86
+ /** Whether any field is dirty (reactive) */
87
+ readonly isDirty: boolean;
88
+ /** Whether form is currently valid (reactive) */
89
+ readonly isValid: boolean;
90
+ /** Whether a draft was restored on mount (reactive) */
91
+ readonly draftRestored: boolean;
92
+ /** Set a field value */
93
+ setField: <K extends keyof z.infer<T>>(field: K, value: z.infer<T>[K]) => void;
94
+ /** Set multiple field values */
95
+ setFields: (values: Partial<z.infer<T>>) => void;
96
+ /** Set a nested field value using dot notation */
97
+ setNestedField: (path: string, value: unknown) => void;
98
+ /** Validate a single field */
99
+ validateField: (field: keyof z.infer<T>) => boolean;
100
+ /** Validate the entire form */
101
+ validate: () => boolean;
102
+ /** Handle form submission */
103
+ handleSubmit: (event?: Event) => Promise<void>;
104
+ /** Reset form to initial values and clear draft */
105
+ reset: () => void;
106
+ /** Clear all errors */
107
+ clearErrors: () => void;
108
+ /** Set a specific error */
109
+ setError: (field: string, message: string) => void;
110
+ /** Mark a field as dirty */
111
+ markDirty: (field: string) => void;
112
+ /** Handle field blur - validates the field */
113
+ handleBlur: (field: keyof z.infer<T>) => void;
114
+ /** Clear the stored draft */
115
+ clearDraft: () => void;
116
+ /** Dismiss the draft restored notification */
117
+ dismissDraftNotification: () => void;
118
+ }
119
+ /**
120
+ * Create a form handler with localStorage persistence
121
+ */
122
+ export declare function usePersistedForm<T extends z.ZodObject<z.ZodRawShape>>(options: UsePersistedFormOptions<T>): UsePersistedFormReturn<T>;
123
+ /**
124
+ * Type helper to extract form data type from schema
125
+ */
126
+ export type InferPersistedFormData<T extends z.ZodObject<z.ZodRawShape>> = z.infer<T>;
@@ -0,0 +1,396 @@
1
+ /**
2
+ * usePersistedForm - Form state management with localStorage persistence
3
+ *
4
+ * Extends useForm with:
5
+ * - Automatic draft saving to localStorage with debounce
6
+ * - Draft restoration on mount
7
+ * - Configurable expiration time
8
+ * - Field exclusion from persistence (e.g., consent fields)
9
+ * - Duplicate submission prevention
10
+ *
11
+ * @example
12
+ * ```svelte
13
+ * <script lang="ts">
14
+ * import { usePersistedForm } from '@classic-homes/theme-svelte';
15
+ * import { z } from 'zod';
16
+ *
17
+ * const schema = z.object({
18
+ * email: z.string().email(),
19
+ * name: z.string().min(1),
20
+ * consent: z.boolean(),
21
+ * });
22
+ *
23
+ * const form = usePersistedForm({
24
+ * schema,
25
+ * initialValues: { email: '', name: '', consent: false },
26
+ * onSubmit: async (data) => {
27
+ * await submitForm(data);
28
+ * },
29
+ * storageKey: 'my-form-draft',
30
+ * excludeFields: ['consent'], // Don't persist consent
31
+ * expirationMs: 7 * 24 * 60 * 60 * 1000, // 7 days
32
+ * onDraftRestored: () => {
33
+ * console.log('Draft was restored');
34
+ * },
35
+ * });
36
+ * </script>
37
+ *
38
+ * <form onsubmit={form.handleSubmit}>
39
+ * <input bind:value={form.data.email} />
40
+ * {#if form.draftRestored}
41
+ * <div>Your previous draft has been restored.</div>
42
+ * {/if}
43
+ * </form>
44
+ * ```
45
+ */
46
+ import { toastStore } from '../stores/toast.svelte.js';
47
+ const DEFAULT_EXPIRATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
48
+ const DEFAULT_DEBOUNCE_MS = 500;
49
+ const DEFAULT_SUBMIT_COOLDOWN_MS = 5000;
50
+ /**
51
+ * Create a form handler with localStorage persistence
52
+ */
53
+ export function usePersistedForm(options) {
54
+ const { schema, initialValues, onSubmit, storageKey, onError, showToastOnError = true, resetOnSuccess = false, errorMessage, successMessage, excludeFields = [], expirationMs = DEFAULT_EXPIRATION_MS, onDraftRestored, debounceMs = DEFAULT_DEBOUNCE_MS, submitCooldownMs = DEFAULT_SUBMIT_COOLDOWN_MS, } = options;
55
+ // Deep clone to avoid reference issues
56
+ const cloneInitialValues = () => JSON.parse(JSON.stringify(initialValues));
57
+ // Reactive state using Svelte 5 runes
58
+ let data = $state(cloneInitialValues());
59
+ let errors = $state({});
60
+ let isSubmitting = $state(false);
61
+ let isSubmitted = $state(false);
62
+ let dirtyFields = $state(new Set());
63
+ let draftRestored = $state(false);
64
+ let lastSubmitTime = $state(0);
65
+ let debounceTimer = null;
66
+ // Derived state
67
+ const isDirty = $derived(dirtyFields.size > 0);
68
+ const isValid = $derived(Object.keys(errors).length === 0);
69
+ /**
70
+ * Check if localStorage is available (also checks for browser environment)
71
+ */
72
+ function isStorageAvailable() {
73
+ if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
74
+ return false;
75
+ }
76
+ try {
77
+ const test = '__storage_test__';
78
+ localStorage.setItem(test, test);
79
+ localStorage.removeItem(test);
80
+ return true;
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ /**
87
+ * Get data to persist (excludes specified fields)
88
+ */
89
+ function getDataToPersist() {
90
+ const dataCopy = { ...data };
91
+ for (const field of excludeFields) {
92
+ delete dataCopy[field];
93
+ }
94
+ return dataCopy;
95
+ }
96
+ /**
97
+ * Save draft to localStorage with debounce
98
+ */
99
+ function saveDraft() {
100
+ if (!isStorageAvailable())
101
+ return;
102
+ if (debounceTimer) {
103
+ clearTimeout(debounceTimer);
104
+ }
105
+ debounceTimer = setTimeout(() => {
106
+ try {
107
+ const draft = {
108
+ data: getDataToPersist(),
109
+ timestamp: Date.now(),
110
+ };
111
+ localStorage.setItem(storageKey, JSON.stringify(draft));
112
+ }
113
+ catch (error) {
114
+ // Silently fail - localStorage may be full or unavailable
115
+ console.warn('Failed to save form draft', error);
116
+ }
117
+ }, debounceMs);
118
+ }
119
+ /**
120
+ * Load draft from localStorage
121
+ */
122
+ function loadDraft() {
123
+ if (!isStorageAvailable())
124
+ return null;
125
+ try {
126
+ const stored = localStorage.getItem(storageKey);
127
+ if (!stored)
128
+ return null;
129
+ const draft = JSON.parse(stored);
130
+ // Check if draft has expired
131
+ if (Date.now() - draft.timestamp > expirationMs) {
132
+ clearDraft();
133
+ return null;
134
+ }
135
+ return draft.data;
136
+ }
137
+ catch {
138
+ // Silently fail - corrupt data
139
+ return null;
140
+ }
141
+ }
142
+ /**
143
+ * Clear draft from localStorage
144
+ */
145
+ function clearDraft() {
146
+ if (!isStorageAvailable())
147
+ return;
148
+ try {
149
+ localStorage.removeItem(storageKey);
150
+ }
151
+ catch {
152
+ // Silently fail
153
+ }
154
+ }
155
+ /**
156
+ * Restore draft on initialization
157
+ */
158
+ function restoreDraft() {
159
+ const draft = loadDraft();
160
+ if (draft && Object.keys(draft).length > 0) {
161
+ // Merge draft with initial data (to keep excluded fields at their defaults)
162
+ data = { ...cloneInitialValues(), ...draft };
163
+ draftRestored = true;
164
+ onDraftRestored?.();
165
+ }
166
+ }
167
+ // Restore draft on initialization (only in browser)
168
+ if (typeof window !== 'undefined') {
169
+ restoreDraft();
170
+ }
171
+ // Watch for form changes and save draft
172
+ $effect(() => {
173
+ // Access data to create dependency
174
+ JSON.stringify(data);
175
+ if (dirtyFields.size > 0) {
176
+ saveDraft();
177
+ }
178
+ });
179
+ /**
180
+ * Set nested value in object using dot notation path
181
+ */
182
+ function setNestedValue(obj, path, value) {
183
+ const keys = path.split('.');
184
+ let current = obj;
185
+ for (let i = 0; i < keys.length - 1; i++) {
186
+ const key = keys[i];
187
+ if (current[key] === undefined || current[key] === null) {
188
+ current[key] = {};
189
+ }
190
+ current = current[key];
191
+ }
192
+ current[keys[keys.length - 1]] = value;
193
+ }
194
+ /**
195
+ * Parse Zod errors into field-keyed error map
196
+ */
197
+ function parseZodErrors(zodError) {
198
+ const fieldErrors = {};
199
+ for (const issue of zodError.issues) {
200
+ const path = issue.path.join('.');
201
+ // Only keep the first error for each field
202
+ if (!fieldErrors[path]) {
203
+ fieldErrors[path] = issue.message;
204
+ }
205
+ }
206
+ return fieldErrors;
207
+ }
208
+ /**
209
+ * Set a single field value
210
+ */
211
+ function setField(field, value) {
212
+ data[field] = value;
213
+ markDirty(field);
214
+ }
215
+ /**
216
+ * Set multiple field values
217
+ */
218
+ function setFields(values) {
219
+ for (const [key, value] of Object.entries(values)) {
220
+ data[key] = value;
221
+ markDirty(key);
222
+ }
223
+ }
224
+ /**
225
+ * Set a nested field value using dot notation
226
+ */
227
+ function setNestedField(path, value) {
228
+ setNestedValue(data, path, value);
229
+ markDirty(path);
230
+ }
231
+ /**
232
+ * Mark a field as dirty
233
+ */
234
+ function markDirty(field) {
235
+ dirtyFields = new Set([...dirtyFields, field]);
236
+ }
237
+ /**
238
+ * Validate a single field against the schema
239
+ */
240
+ function validateField(field) {
241
+ const fieldSchema = schema.shape[field];
242
+ if (!fieldSchema)
243
+ return true;
244
+ const fieldValue = data[field];
245
+ const result = fieldSchema.safeParse(fieldValue);
246
+ if (result.success) {
247
+ // Clear error for this field
248
+ const newErrors = { ...errors };
249
+ delete newErrors[field];
250
+ errors = newErrors;
251
+ return true;
252
+ }
253
+ else {
254
+ // Set error for this field
255
+ errors = {
256
+ ...errors,
257
+ [field]: result.error.issues[0]?.message || 'Invalid value',
258
+ };
259
+ return false;
260
+ }
261
+ }
262
+ /**
263
+ * Validate the entire form
264
+ */
265
+ function validate() {
266
+ const result = schema.safeParse(data);
267
+ if (result.success) {
268
+ errors = {};
269
+ return true;
270
+ }
271
+ else {
272
+ errors = parseZodErrors(result.error);
273
+ return false;
274
+ }
275
+ }
276
+ /**
277
+ * Handle field blur - validates the field
278
+ */
279
+ function handleBlur(field) {
280
+ markDirty(field);
281
+ validateField(field);
282
+ }
283
+ /**
284
+ * Handle form submission
285
+ */
286
+ async function handleSubmit(event) {
287
+ event?.preventDefault();
288
+ // Check for duplicate submission
289
+ const now = Date.now();
290
+ if (now - lastSubmitTime < submitCooldownMs) {
291
+ toastStore.warning('Please wait before submitting again');
292
+ return;
293
+ }
294
+ isSubmitted = true;
295
+ // Validate all fields
296
+ if (!validate()) {
297
+ const errorCount = Object.keys(errors).length;
298
+ if (showToastOnError) {
299
+ toastStore.error(errorMessage || `Please fix ${errorCount} validation error${errorCount > 1 ? 's' : ''}`);
300
+ }
301
+ return;
302
+ }
303
+ isSubmitting = true;
304
+ try {
305
+ await onSubmit(data);
306
+ lastSubmitTime = now;
307
+ // Clear draft on successful submission
308
+ clearDraft();
309
+ if (successMessage) {
310
+ toastStore.success(successMessage);
311
+ }
312
+ if (resetOnSuccess) {
313
+ reset();
314
+ }
315
+ }
316
+ catch (error) {
317
+ // Reset cooldown on error to allow retry
318
+ lastSubmitTime = 0;
319
+ const err = error instanceof Error ? error : new Error(String(error));
320
+ if (onError) {
321
+ onError(err);
322
+ }
323
+ if (showToastOnError) {
324
+ toastStore.error(errorMessage || err.message || 'An error occurred');
325
+ }
326
+ }
327
+ finally {
328
+ isSubmitting = false;
329
+ }
330
+ }
331
+ /**
332
+ * Reset form to initial values and clear draft
333
+ */
334
+ function reset() {
335
+ data = cloneInitialValues();
336
+ errors = {};
337
+ isSubmitted = false;
338
+ dirtyFields = new Set();
339
+ draftRestored = false;
340
+ clearDraft();
341
+ }
342
+ /**
343
+ * Clear all errors
344
+ */
345
+ function clearErrors() {
346
+ errors = {};
347
+ }
348
+ /**
349
+ * Set a specific error manually
350
+ */
351
+ function setError(field, message) {
352
+ errors = { ...errors, [field]: message };
353
+ }
354
+ /**
355
+ * Dismiss the draft restored notification
356
+ */
357
+ function dismissDraftNotification() {
358
+ draftRestored = false;
359
+ }
360
+ return {
361
+ get data() {
362
+ return data;
363
+ },
364
+ get errors() {
365
+ return errors;
366
+ },
367
+ get isSubmitting() {
368
+ return isSubmitting;
369
+ },
370
+ get isSubmitted() {
371
+ return isSubmitted;
372
+ },
373
+ get isDirty() {
374
+ return isDirty;
375
+ },
376
+ get isValid() {
377
+ return isValid;
378
+ },
379
+ get draftRestored() {
380
+ return draftRestored;
381
+ },
382
+ setField,
383
+ setFields,
384
+ setNestedField,
385
+ validateField,
386
+ validate,
387
+ handleSubmit,
388
+ reset,
389
+ clearErrors,
390
+ setError,
391
+ markDirty,
392
+ handleBlur,
393
+ clearDraft,
394
+ dismissDraftNotification,
395
+ };
396
+ }
@@ -72,5 +72,5 @@ export { cn } from './utils.js';
72
72
  export { tv, type VariantProps } from 'tailwind-variants';
73
73
  export { validateNonEmptyArray, validateRequired, validateOneOf, validateRange, validateProps, createValidator, type ValidationResult, } from './validation.js';
74
74
  export { perfStart, perfEnd, measure, measureAsync, getPerformanceEntries, clearPerformanceEntries, createPerfMonitor, type PerformanceMark, } from './performance.js';
75
- export { useForm, useAsync, runAsync, type UseFormOptions, type UseFormReturn, type FieldError, type FormState, type InferFormData, type UseAsyncOptions, type UseAsyncReturn, } from './composables/index.js';
75
+ export { useForm, useAsync, runAsync, usePersistedForm, type UseFormOptions, type UseFormReturn, type FieldError, type FormState, type InferFormData, type UseAsyncOptions, type UseAsyncReturn, type UsePersistedFormOptions, type UsePersistedFormReturn, type InferPersistedFormData, } from './composables/index.js';
76
76
  export * from './schemas/index.js';
package/dist/lib/index.js CHANGED
@@ -89,6 +89,6 @@ export { validateNonEmptyArray, validateRequired, validateOneOf, validateRange,
89
89
  // Performance monitoring utilities (dev-only, tree-shaken in production)
90
90
  export { perfStart, perfEnd, measure, measureAsync, getPerformanceEntries, clearPerformanceEntries, createPerfMonitor, } from './performance.js';
91
91
  // Composables - reusable Svelte 5 state management patterns
92
- export { useForm, useAsync, runAsync, } from './composables/index.js';
92
+ export { useForm, useAsync, runAsync, usePersistedForm, } from './composables/index.js';
93
93
  // Validation schemas - Zod schemas for forms and data validation
94
94
  export * from './schemas/index.js';
@@ -246,14 +246,14 @@ export declare function paginatedResponseSchema<T extends z.ZodTypeAny>(itemSche
246
246
  pageSize: z.ZodNumber;
247
247
  totalPages: z.ZodNumber;
248
248
  }, "strip", z.ZodTypeAny, {
249
- items: T["_output"][];
250
249
  page: number;
250
+ items: T["_output"][];
251
251
  pageSize: number;
252
252
  total: number;
253
253
  totalPages: number;
254
254
  }, {
255
- items: T["_input"][];
256
255
  page: number;
256
+ items: T["_input"][];
257
257
  pageSize: number;
258
258
  total: number;
259
259
  totalPages: number;
@@ -235,8 +235,16 @@ export interface PublicLayoutProps {
235
235
  copyright?: string;
236
236
  /** Custom logo snippet */
237
237
  logo?: Snippet;
238
+ /** Logo subtitle for default logo (e.g., "THEME", "MY HOME") */
239
+ logoSubtitle?: string;
240
+ /** Logo environment indicator for default logo */
241
+ logoEnvironment?: 'local' | 'dev' | 'demo';
238
242
  /** Custom header end content */
239
243
  headerEnd?: Snippet;
244
+ /** Use strong accent border on footer (10px top border) */
245
+ strongFooterBorder?: boolean;
246
+ /** Use dark footer variant */
247
+ darkFooter?: boolean;
240
248
  /** Header search configuration */
241
249
  headerSearch?: HeaderSearchConfig;
242
250
  /** Main content */
@@ -332,6 +340,10 @@ export interface HeaderProps {
332
340
  /** Additional classes */
333
341
  class?: string;
334
342
  }
343
+ /**
344
+ * Footer visual variant
345
+ */
346
+ export type FooterVariant = 'default' | 'dark';
335
347
  /**
336
348
  * Props for Footer component
337
349
  */
@@ -342,6 +354,10 @@ export interface FooterProps {
342
354
  copyright?: string;
343
355
  /** Show logo in footer */
344
356
  showLogo?: boolean;
357
+ /** Use strong accent border (10px top border) */
358
+ strongBorder?: boolean;
359
+ /** Dark variant with brand colors */
360
+ variant?: FooterVariant;
345
361
  /** Custom content */
346
362
  children?: Snippet;
347
363
  /** Additional classes */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",