svelte-theme-picker 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,6 +74,155 @@ interface ThemePickerConfig {
74
74
  }
75
75
  ```
76
76
 
77
+ ## SSR Support (Preventing Flash)
78
+
79
+ When using SvelteKit with SSR, themes are applied after JavaScript loads, causing a flash of unstyled content (FOUC). Use the `ThemeHead` component or SSR utilities to prevent this.
80
+
81
+ ### Using ThemeHead Component (Recommended)
82
+
83
+ The easiest way to prevent theme flash in SvelteKit:
84
+
85
+ ```svelte
86
+ <!-- src/routes/+layout.svelte -->
87
+ <script>
88
+ import { ThemeHead, ThemePicker, defaultThemes } from 'svelte-theme-picker';
89
+ </script>
90
+
91
+ <ThemeHead
92
+ themes={defaultThemes}
93
+ storageKey="my-app-theme"
94
+ defaultTheme="dreamy"
95
+ preloadFonts={true}
96
+ />
97
+
98
+ <ThemePicker config={{ themes: defaultThemes, storageKey: 'my-app-theme' }} />
99
+
100
+ <slot />
101
+ ```
102
+
103
+ The `ThemeHead` component:
104
+ - Injects a blocking script that applies CSS variables before first paint
105
+ - Adds `no-transitions` class to prevent transition animations during hydration
106
+ - Optionally preloads Google Fonts for all themes
107
+
108
+ ### ThemeHead Props
109
+
110
+ | Prop | Type | Default | Description |
111
+ |------|------|---------|-------------|
112
+ | `themes` | `Record<string, Theme>` | required | All available themes |
113
+ | `storageKey` | `string` | `'svelte-theme-picker'` | localStorage key |
114
+ | `defaultTheme` | `string` | first theme | Default theme ID |
115
+ | `cssVarPrefix` | `string` | `''` | CSS variable prefix |
116
+ | `preventTransitions` | `boolean` | `true` | Prevent transition animations during hydration |
117
+ | `preloadFonts` | `boolean` | `false` | Enable Google Fonts preloading |
118
+ | `fontConfig` | `FontConfig` | `{ provider: 'google' }` | Font preloading configuration |
119
+
120
+ ### Using SSR Utilities Directly
121
+
122
+ For more control, use the SSR utilities to generate blocking scripts:
123
+
124
+ ```typescript
125
+ // src/hooks.server.ts
126
+ import { generateSSRHead } from 'svelte-theme-picker';
127
+ import { myThemes } from './themes';
128
+
129
+ export async function handle({ event, resolve }) {
130
+ return resolve(event, {
131
+ transformPageChunk: ({ html }) => {
132
+ const ssrHead = generateSSRHead({
133
+ themes: myThemes,
134
+ storageKey: 'my-app-theme',
135
+ defaultTheme: 'dreamy',
136
+ });
137
+ return html.replace('</head>', `${ssrHead}</head>`);
138
+ }
139
+ });
140
+ }
141
+ ```
142
+
143
+ ### Removing No-Transitions Class
144
+
145
+ After hydration, remove the `no-transitions` class to enable animations:
146
+
147
+ ```svelte
148
+ <!-- src/routes/+layout.svelte -->
149
+ <script>
150
+ import { browser } from '$app/environment';
151
+ import { onMount } from 'svelte';
152
+
153
+ onMount(() => {
154
+ // Small delay to ensure hydration is complete
155
+ setTimeout(() => {
156
+ document.documentElement.classList.remove('no-transitions');
157
+ }, 50);
158
+ });
159
+ </script>
160
+ ```
161
+
162
+ ### SSR Utility Functions
163
+
164
+ ```typescript
165
+ import {
166
+ generateBlockingScript, // Generate minified blocking script string
167
+ generateSSRHead, // Generate complete head HTML (script + styles + fonts)
168
+ applyThemeToElement, // Apply theme to an element (sync, for blocking scripts)
169
+ getThemeCSS, // Get CSS variable declarations as string
170
+ extractFonts, // Extract font names from a theme
171
+ generateFontPreloadLinks, // Generate Google Fonts preload links
172
+ themeSchema, // CSS variable mapping (for consistency)
173
+ } from 'svelte-theme-picker';
174
+ ```
175
+
176
+ ## Cross-Tab Synchronization
177
+
178
+ Enable automatic theme sync across browser tabs:
179
+
180
+ ```svelte
181
+ <script>
182
+ import { createThemeStore, ThemePicker } from 'svelte-theme-picker';
183
+
184
+ const store = createThemeStore({
185
+ syncTabs: true, // Enable cross-tab sync
186
+ storageKey: 'my-app-theme',
187
+ });
188
+ </script>
189
+
190
+ <ThemePicker store={store} />
191
+ ```
192
+
193
+ When a user changes the theme in one tab, all other tabs will automatically update. The store listens for `storage` events and syncs the theme state.
194
+
195
+ To clean up listeners when done:
196
+
197
+ ```typescript
198
+ store.destroy(); // Removes storage event listener
199
+ ```
200
+
201
+ ## External Store Synchronization
202
+
203
+ If you're using an external store and the `ThemePicker` doesn't update when the store changes externally, use the `{#key}` pattern to force a re-render:
204
+
205
+ ```svelte
206
+ <script>
207
+ import { ThemePicker, createThemeStore } from 'svelte-theme-picker';
208
+
209
+ const themeStore = createThemeStore({ /* config */ });
210
+ let currentThemeId = $state('dreamy');
211
+
212
+ // Subscribe to track external changes
213
+ themeStore.subscribe((themeId) => {
214
+ currentThemeId = themeId;
215
+ });
216
+ </script>
217
+
218
+ <!-- Force re-render when theme changes externally -->
219
+ {#key currentThemeId}
220
+ <ThemePicker store={themeStore} />
221
+ {/key}
222
+ ```
223
+
224
+ > **Note**: The `ThemePicker` component captures its configuration once at mount. This is intentional for performance. Use `{#key}` to create a new instance when props need to change.
225
+
77
226
  ## Headless Mode (No UI)
78
227
 
79
228
  The `ThemePicker` component is completely optional. You can use just the store and utilities for full programmatic control without rendering any UI. This is useful when:
@@ -569,6 +718,57 @@ You can provide your own themes:
569
718
  <ThemePicker config={{ themes: myThemes, defaultTheme: 'my-theme' }} />
570
719
  ```
571
720
 
721
+ ## Styling the Picker
722
+
723
+ The `ThemePicker` component can be customized using CSS custom properties. The picker automatically uses your theme's CSS variables as fallbacks, so it adapts to your theme.
724
+
725
+ ### Picker CSS Properties
726
+
727
+ Override these to customize the picker appearance:
728
+
729
+ ```css
730
+ /* In your global CSS or :root */
731
+ :root {
732
+ /* Colors */
733
+ --stp-bg: var(--bg-card); /* Panel background */
734
+ --stp-bg-hover: var(--bg-glow); /* Hover state background */
735
+ --stp-bg-active: ...; /* Active/selected item */
736
+ --stp-border: ...; /* Border color */
737
+ --stp-text: var(--text-primary); /* Primary text */
738
+ --stp-text-muted: var(--text-muted);/* Secondary text */
739
+ --stp-accent: var(--accent-1); /* Accent color */
740
+ --stp-accent-glow: ...; /* Glow effect color */
741
+
742
+ /* Trigger button */
743
+ --stp-trigger-bg: ...; /* Trigger background gradient */
744
+ --stp-trigger-color: var(--bg-deep);/* Trigger icon color */
745
+
746
+ /* Layout */
747
+ --stp-radius: 12px; /* Panel border radius */
748
+ --stp-radius-sm: 8px; /* Item border radius */
749
+ --stp-space: 12px; /* Standard spacing */
750
+ --stp-space-sm: 8px; /* Small spacing */
751
+ --stp-transition: 0.2s ease; /* Transition timing */
752
+ }
753
+ ```
754
+
755
+ The picker uses `color-mix()` for calculated colors (active states, borders) that automatically adjust to your theme. No `!important` overrides should be needed.
756
+
757
+ ### Theme Mode
758
+
759
+ Themes can declare a `mode` property (`'light'` or `'dark'`) to help the picker adjust its styling:
760
+
761
+ ```typescript
762
+ const myTheme: Theme = {
763
+ name: 'My Light Theme',
764
+ description: 'A light theme',
765
+ mode: 'light', // or 'dark'
766
+ colors: { ... },
767
+ fonts: { ... },
768
+ effects: { ... },
769
+ };
770
+ ```
771
+
572
772
  ## CSS Variables
573
773
 
574
774
  The theme picker applies these CSS variables to your document:
@@ -669,18 +869,28 @@ Full TypeScript support with exported types:
669
869
 
670
870
  ```typescript
671
871
  import type {
872
+ // Theme types
672
873
  Theme,
673
874
  ThemeColors,
674
875
  ThemeFonts,
675
876
  ThemeEffects,
676
877
  ThemePickerConfig,
878
+ // Catalog types
677
879
  ThemeCatalog,
678
880
  ThemeCatalogEntry,
679
881
  ThemeMeta,
680
882
  ThemeFilterOptions,
883
+ // SSR types
884
+ SSRConfig,
885
+ FontConfig,
886
+ ThemeSchema,
681
887
  } from 'svelte-theme-picker';
682
888
  ```
683
889
 
890
+ ## Migration Guide
891
+
892
+ Upgrading from a previous version? See [MIGRATION.md](./MIGRATION.md) for breaking changes and migration steps.
893
+
684
894
  ## License
685
895
 
686
896
  MIT
@@ -0,0 +1,69 @@
1
+ <script lang="ts">
2
+ import { generateBlockingScript, extractFonts, generateFontPreloadLinks } from './ssr.js';
3
+ import type { Theme, FontConfig } from './types.js';
4
+
5
+ interface Props {
6
+ /** All available themes */
7
+ themes: Record<string, Theme>;
8
+ /** localStorage key for theme persistence */
9
+ storageKey?: string;
10
+ /** Default theme ID if none is stored */
11
+ defaultTheme?: string;
12
+ /** CSS variable prefix */
13
+ cssVarPrefix?: string;
14
+ /** Whether to prevent transitions during hydration (default: true) */
15
+ preventTransitions?: boolean;
16
+ /** Enable font preloading */
17
+ preloadFonts?: boolean;
18
+ /** Font configuration for preloading */
19
+ fontConfig?: FontConfig;
20
+ }
21
+
22
+ let {
23
+ themes,
24
+ storageKey = 'svelte-theme-picker',
25
+ defaultTheme,
26
+ cssVarPrefix = '',
27
+ preventTransitions = true,
28
+ preloadFonts = false,
29
+ fontConfig = { provider: 'google' },
30
+ }: Props = $props();
31
+
32
+ // Generate the blocking script
33
+ const blockingScript = $derived(generateBlockingScript({
34
+ themes,
35
+ storageKey,
36
+ defaultTheme,
37
+ cssVarPrefix,
38
+ preventTransitions,
39
+ }));
40
+
41
+ // Generate font preload links if enabled
42
+ const fontLinks = $derived.by(() => {
43
+ if (!preloadFonts) return '';
44
+
45
+ const allFonts = new Set<string>();
46
+ for (const theme of Object.values(themes)) {
47
+ for (const font of extractFonts(theme)) {
48
+ allFonts.add(font);
49
+ }
50
+ }
51
+ return generateFontPreloadLinks([...allFonts], fontConfig);
52
+ });
53
+
54
+ // No-transitions CSS
55
+ const noTransitionsCSS = '.no-transitions,.no-transitions *,.no-transitions *::before,.no-transitions *::after{transition:none!important;animation:none!important}';
56
+ </script>
57
+
58
+ <svelte:head>
59
+ {#if preloadFonts && fontLinks}
60
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
61
+ {@html fontLinks}
62
+ {/if}
63
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
64
+ {@html `<script>${blockingScript}</script>`}
65
+ {#if preventTransitions}
66
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
67
+ {@html `<style>${noTransitionsCSS}</style>`}
68
+ {/if}
69
+ </svelte:head>
@@ -0,0 +1,20 @@
1
+ import type { Theme, FontConfig } from './types.js';
2
+ interface Props {
3
+ /** All available themes */
4
+ themes: Record<string, Theme>;
5
+ /** localStorage key for theme persistence */
6
+ storageKey?: string;
7
+ /** Default theme ID if none is stored */
8
+ defaultTheme?: string;
9
+ /** CSS variable prefix */
10
+ cssVarPrefix?: string;
11
+ /** Whether to prevent transitions during hydration (default: true) */
12
+ preventTransitions?: boolean;
13
+ /** Enable font preloading */
14
+ preloadFonts?: boolean;
15
+ /** Font configuration for preloading */
16
+ fontConfig?: FontConfig;
17
+ }
18
+ declare const ThemeHead: import("svelte").Component<Props, {}, "">;
19
+ type ThemeHead = ReturnType<typeof ThemeHead>;
20
+ export default ThemeHead;
@@ -171,15 +171,50 @@
171
171
  </div>
172
172
 
173
173
  <style>
174
+ /*
175
+ * CSS Custom Properties for ThemePicker
176
+ * Override these to customize the picker appearance:
177
+ *
178
+ * Colors:
179
+ * --stp-bg : Panel background color
180
+ * --stp-bg-hover : Hover state background
181
+ * --stp-bg-active : Active/selected item background
182
+ * --stp-border : Border color
183
+ * --stp-text : Primary text color
184
+ * --stp-text-muted : Secondary/muted text color
185
+ * --stp-accent : Accent color (trigger, scrollbar, checks)
186
+ * --stp-accent-glow : Glow effect color for trigger
187
+ *
188
+ * Trigger button:
189
+ * --stp-trigger-bg : Trigger button background
190
+ * --stp-trigger-color : Trigger button icon color
191
+ *
192
+ * Layout:
193
+ * --stp-radius : Panel border radius
194
+ * --stp-radius-sm : Button/item border radius
195
+ * --stp-space : Standard spacing
196
+ * --stp-space-sm : Small spacing
197
+ * --stp-transition : Transition duration/easing
198
+ *
199
+ * Example usage:
200
+ * :root {
201
+ * --stp-bg: var(--bg-card);
202
+ * --stp-text: var(--text-primary);
203
+ * --stp-accent: var(--accent-1);
204
+ * }
205
+ */
174
206
  .stp-theme-picker {
175
- --stp-bg: #2a2a4a;
176
- --stp-bg-hover: #3d3d6b;
177
- --stp-bg-active: rgba(168, 85, 247, 0.15);
178
- --stp-border: rgba(255, 255, 255, 0.1);
179
- --stp-text: #e8e0f0;
180
- --stp-text-muted: #9090b0;
181
- --stp-accent: #a855f7;
182
- --stp-accent-glow: rgba(168, 85, 247, 0.3);
207
+ /* Fallback to theme variables, then to default values */
208
+ --stp-bg: var(--bg-card, #2a2a4a);
209
+ --stp-bg-hover: var(--bg-glow, #3d3d6b);
210
+ --stp-bg-active: color-mix(in srgb, var(--stp-accent, var(--accent-1, #a855f7)) 15%, transparent);
211
+ --stp-border: color-mix(in srgb, var(--stp-text, var(--text-primary, #e8e0f0)) 10%, transparent);
212
+ --stp-text: var(--text-primary, #e8e0f0);
213
+ --stp-text-muted: var(--text-muted, #9090b0);
214
+ --stp-accent: var(--accent-1, #a855f7);
215
+ --stp-accent-glow: color-mix(in srgb, var(--stp-accent) 30%, transparent);
216
+ --stp-trigger-bg: linear-gradient(135deg, var(--primary-3, #c9a0dc), var(--stp-accent));
217
+ --stp-trigger-color: var(--bg-deep, #1a1a2e);
183
218
  --stp-radius: 12px;
184
219
  --stp-radius-sm: 8px;
185
220
  --stp-space: 12px;
@@ -221,15 +256,15 @@
221
256
  height: 48px;
222
257
  border-radius: 50%;
223
258
  border: none;
224
- background: linear-gradient(135deg, #c9a0dc, #a855f7);
225
- color: #1a1a2e;
259
+ background: var(--stp-trigger-bg);
260
+ color: var(--stp-trigger-color);
226
261
  cursor: pointer;
227
262
  display: flex;
228
263
  align-items: center;
229
264
  justify-content: center;
230
265
  box-shadow:
231
266
  0 4px 20px var(--stp-accent-glow),
232
- 0 0 40px rgba(168, 85, 247, 0.1);
267
+ 0 0 40px color-mix(in srgb, var(--stp-accent) 10%, transparent);
233
268
  transition: transform var(--stp-transition), box-shadow var(--stp-transition);
234
269
  /* Performance: Hint browser to prepare GPU layer for animations */
235
270
  will-change: transform;
@@ -239,7 +274,7 @@
239
274
  transform: scale(1.1);
240
275
  box-shadow:
241
276
  0 8px 30px var(--stp-accent-glow),
242
- 0 0 60px rgba(168, 85, 247, 0.2);
277
+ 0 0 60px color-mix(in srgb, var(--stp-accent) 20%, transparent);
243
278
  }
244
279
 
245
280
  .stp-backdrop {
@@ -251,7 +286,7 @@
251
286
  .stp-panel {
252
287
  position: absolute;
253
288
  width: 320px;
254
- background: linear-gradient(135deg, rgba(42, 42, 74, 0.95), rgba(61, 61, 107, 0.9));
289
+ background: color-mix(in srgb, var(--stp-bg) 95%, transparent);
255
290
  backdrop-filter: blur(16px);
256
291
  -webkit-backdrop-filter: blur(16px); /* Safari support */
257
292
  border-radius: var(--stp-radius);
@@ -409,7 +444,7 @@
409
444
  }
410
445
 
411
446
  .stp-list::-webkit-scrollbar-thumb {
412
- background: rgba(168, 85, 247, 0.5);
447
+ background: color-mix(in srgb, var(--stp-accent) 50%, transparent);
413
448
  border-radius: 3px;
414
449
  }
415
450
 
@@ -482,7 +517,7 @@
482
517
 
483
518
  .stp-check {
484
519
  flex-shrink: 0;
485
- color: #8ad4d4;
520
+ color: var(--accent-2, #8ad4d4);
486
521
  }
487
522
 
488
523
  @media (max-width: 640px) {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { default as ThemePicker } from './ThemePicker.svelte';
2
- export type { Theme, ThemeColors, ThemeFonts, ThemeEffects, ThemePickerConfig, ThemeMeta, ThemeCatalogEntry, ThemeCatalog, ThemeFilterOptions, } from './types.js';
2
+ export { default as ThemeHead } from './ThemeHead.svelte';
3
+ export type { Theme, ThemeColors, ThemeFonts, ThemeEffects, ThemePickerConfig, ThemeMeta, ThemeCatalogEntry, ThemeCatalog, ThemeFilterOptions, SSRConfig, FontConfig, ThemeSchema, } from './types.js';
3
4
  export { createThemeStore, applyTheme, themeStore, type ThemeStore, } from './store.js';
4
5
  export { defaultThemes, defaultThemeCatalog, DEFAULT_THEME_ID, dreamy, cyberpunk, sunset, ocean, mono, sakura, aurora, galaxy, milk, light, } from './themes.js';
5
6
  export { themesToCatalog, catalogToThemes, filterCatalog, getActiveThemes, getThemesByTag, getThemesByAnyTag, sortCatalog, getCatalogTags, createCatalogEntry, mergeCatalogs, loadCatalogFromJSON, } from './catalog.js';
7
+ export { generateBlockingScript, generateSSRHead, applyThemeToElement, getThemeCSS, extractFonts, generateFontPreloadLinks, themeSchema, } from './ssr.js';
package/dist/index.js CHANGED
@@ -1,8 +1,11 @@
1
1
  // Components
2
2
  export { default as ThemePicker } from './ThemePicker.svelte';
3
+ export { default as ThemeHead } from './ThemeHead.svelte';
3
4
  // Store
4
5
  export { createThemeStore, applyTheme, themeStore, } from './store.js';
5
6
  // Default themes
6
7
  export { defaultThemes, defaultThemeCatalog, DEFAULT_THEME_ID, dreamy, cyberpunk, sunset, ocean, mono, sakura, aurora, galaxy, milk, light, } from './themes.js';
7
8
  // Catalog utilities
8
9
  export { themesToCatalog, catalogToThemes, filterCatalog, getActiveThemes, getThemesByTag, getThemesByAnyTag, sortCatalog, getCatalogTags, createCatalogEntry, mergeCatalogs, loadCatalogFromJSON, } from './catalog.js';
10
+ // SSR utilities
11
+ export { generateBlockingScript, generateSSRHead, applyThemeToElement, getThemeCSS, extractFonts, generateFontPreloadLinks, themeSchema, } from './ssr.js';
package/dist/ssr.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { Theme, SSRConfig, FontConfig, ThemeSchema } from './types.js';
2
+ /**
3
+ * CSS variable schema - maps theme properties to CSS variable names.
4
+ * Use this to ensure consistency between blocking scripts and runtime.
5
+ */
6
+ export declare const themeSchema: ThemeSchema;
7
+ /**
8
+ * Get CSS variable declarations for a theme as a string.
9
+ * Useful for generating inline styles or CSS rules.
10
+ */
11
+ export declare function getThemeCSS(theme: Theme, prefix?: string): string;
12
+ /**
13
+ * Apply theme CSS variables to an HTML element synchronously.
14
+ * This is the same logic as the runtime applyTheme but without requestAnimationFrame,
15
+ * making it suitable for use in blocking scripts.
16
+ */
17
+ export declare function applyThemeToElement(element: HTMLElement, theme: Theme, prefix?: string): void;
18
+ /**
19
+ * Extract font family names from a theme.
20
+ * Parses CSS font-family values to extract the primary font name.
21
+ */
22
+ export declare function extractFonts(theme: Theme): string[];
23
+ /**
24
+ * Generate Google Fonts preload links for the given font names.
25
+ */
26
+ export declare function generateFontPreloadLinks(fonts: string[], config?: FontConfig): string;
27
+ /**
28
+ * Generate a blocking script that prevents theme flash on page load.
29
+ * This script should be placed in the <head> of your HTML before any other scripts.
30
+ *
31
+ * The script:
32
+ * 1. Reads the stored theme from localStorage
33
+ * 2. Applies CSS variables immediately (before paint)
34
+ * 3. Optionally adds a 'no-transitions' class to prevent transition animations during hydration
35
+ * 4. Optionally preloads fonts for the current theme
36
+ */
37
+ export declare function generateBlockingScript(config: SSRConfig): string;
38
+ /**
39
+ * Generate a complete SSR script tag that can be inserted into HTML.
40
+ * Includes the blocking script and optionally font preload links.
41
+ */
42
+ export declare function generateSSRHead(config: SSRConfig): string;
package/dist/ssr.js ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * CSS variable schema - maps theme properties to CSS variable names.
3
+ * Use this to ensure consistency between blocking scripts and runtime.
4
+ */
5
+ export const themeSchema = {
6
+ colors: {
7
+ bgDeep: 'bg-deep',
8
+ bgMid: 'bg-mid',
9
+ bgCard: 'bg-card',
10
+ bgGlow: 'bg-glow',
11
+ bgOverlay: 'bg-overlay',
12
+ primary1: 'primary-1',
13
+ primary2: 'primary-2',
14
+ primary3: 'primary-3',
15
+ primary4: 'primary-4',
16
+ primary5: 'primary-5',
17
+ primary6: 'primary-6',
18
+ accent1: 'accent-1',
19
+ accent2: 'accent-2',
20
+ accent3: 'accent-3',
21
+ textPrimary: 'text-primary',
22
+ textSecondary: 'text-secondary',
23
+ textMuted: 'text-muted',
24
+ },
25
+ fonts: {
26
+ heading: 'font-heading',
27
+ body: 'font-body',
28
+ mono: 'font-mono',
29
+ },
30
+ effects: {
31
+ shadowGlow: 'shadow-glow',
32
+ glowColor: 'glow-color',
33
+ glowIntensity: 'glow-intensity',
34
+ },
35
+ };
36
+ /**
37
+ * Get CSS variable declarations for a theme as a string.
38
+ * Useful for generating inline styles or CSS rules.
39
+ */
40
+ export function getThemeCSS(theme, prefix = '') {
41
+ const p = prefix ? `${prefix}-` : '';
42
+ const vars = [];
43
+ // Colors
44
+ for (const [key, cssVar] of Object.entries(themeSchema.colors)) {
45
+ const value = theme.colors[key];
46
+ vars.push(`--${p}${cssVar}: ${value}`);
47
+ }
48
+ // Fonts
49
+ for (const [key, cssVar] of Object.entries(themeSchema.fonts)) {
50
+ const value = theme.fonts[key];
51
+ vars.push(`--${p}${cssVar}: ${value}`);
52
+ }
53
+ // Effects
54
+ vars.push(`--${p}${themeSchema.effects.shadowGlow}: 0 0 40px ${theme.effects.glowColor}`);
55
+ vars.push(`--${p}${themeSchema.effects.glowColor}: ${theme.effects.glowColor}`);
56
+ vars.push(`--${p}${themeSchema.effects.glowIntensity}: ${theme.effects.glowIntensity}`);
57
+ return vars.join('; ');
58
+ }
59
+ /**
60
+ * Apply theme CSS variables to an HTML element synchronously.
61
+ * This is the same logic as the runtime applyTheme but without requestAnimationFrame,
62
+ * making it suitable for use in blocking scripts.
63
+ */
64
+ export function applyThemeToElement(element, theme, prefix = '') {
65
+ const p = prefix ? `${prefix}-` : '';
66
+ // Background colors
67
+ element.style.setProperty(`--${p}bg-deep`, theme.colors.bgDeep);
68
+ element.style.setProperty(`--${p}bg-mid`, theme.colors.bgMid);
69
+ element.style.setProperty(`--${p}bg-card`, theme.colors.bgCard);
70
+ element.style.setProperty(`--${p}bg-glow`, theme.colors.bgGlow);
71
+ element.style.setProperty(`--${p}bg-overlay`, theme.colors.bgOverlay);
72
+ // Primary palette
73
+ element.style.setProperty(`--${p}primary-1`, theme.colors.primary1);
74
+ element.style.setProperty(`--${p}primary-2`, theme.colors.primary2);
75
+ element.style.setProperty(`--${p}primary-3`, theme.colors.primary3);
76
+ element.style.setProperty(`--${p}primary-4`, theme.colors.primary4);
77
+ element.style.setProperty(`--${p}primary-5`, theme.colors.primary5);
78
+ element.style.setProperty(`--${p}primary-6`, theme.colors.primary6);
79
+ // Accent colors
80
+ element.style.setProperty(`--${p}accent-1`, theme.colors.accent1);
81
+ element.style.setProperty(`--${p}accent-2`, theme.colors.accent2);
82
+ element.style.setProperty(`--${p}accent-3`, theme.colors.accent3);
83
+ // Text colors
84
+ element.style.setProperty(`--${p}text-primary`, theme.colors.textPrimary);
85
+ element.style.setProperty(`--${p}text-secondary`, theme.colors.textSecondary);
86
+ element.style.setProperty(`--${p}text-muted`, theme.colors.textMuted);
87
+ // Fonts
88
+ element.style.setProperty(`--${p}font-heading`, theme.fonts.heading);
89
+ element.style.setProperty(`--${p}font-body`, theme.fonts.body);
90
+ element.style.setProperty(`--${p}font-mono`, theme.fonts.mono);
91
+ // Effects
92
+ element.style.setProperty(`--${p}shadow-glow`, `0 0 40px ${theme.effects.glowColor}`);
93
+ element.style.setProperty(`--${p}glow-color`, theme.effects.glowColor);
94
+ element.style.setProperty(`--${p}glow-intensity`, theme.effects.glowIntensity.toString());
95
+ }
96
+ /**
97
+ * Extract font family names from a theme.
98
+ * Parses CSS font-family values to extract the primary font name.
99
+ */
100
+ export function extractFonts(theme) {
101
+ const fonts = [];
102
+ const fontValues = [theme.fonts.heading, theme.fonts.body, theme.fonts.mono];
103
+ for (const fontValue of fontValues) {
104
+ // Extract the first font family from a CSS font-family value
105
+ // e.g., "'Quicksand', sans-serif" -> "Quicksand"
106
+ const match = fontValue.match(/^['"]?([^'",]+)/);
107
+ if (match && match[1]) {
108
+ const fontName = match[1].trim();
109
+ // Exclude generic font families
110
+ if (!['sans-serif', 'serif', 'monospace', 'cursive', 'fantasy', 'system-ui'].includes(fontName.toLowerCase())) {
111
+ if (!fonts.includes(fontName)) {
112
+ fonts.push(fontName);
113
+ }
114
+ }
115
+ }
116
+ }
117
+ return fonts;
118
+ }
119
+ /**
120
+ * Generate Google Fonts preload links for the given font names.
121
+ */
122
+ export function generateFontPreloadLinks(fonts, config = {}) {
123
+ const { provider = 'google', weights = {} } = config;
124
+ if (provider !== 'google' || fonts.length === 0) {
125
+ return '';
126
+ }
127
+ const defaultWeights = {
128
+ heading: weights.heading || [400, 600, 700],
129
+ body: weights.body || [400, 500],
130
+ mono: weights.mono || [400],
131
+ };
132
+ // For simplicity, use all weights for each font
133
+ const allWeights = [...new Set([
134
+ ...defaultWeights.heading,
135
+ ...defaultWeights.body,
136
+ ...defaultWeights.mono,
137
+ ])].sort((a, b) => a - b);
138
+ const fontSpecs = fonts.map(font => {
139
+ const weightStr = allWeights.join(';');
140
+ return `family=${encodeURIComponent(font)}:wght@${weightStr}`;
141
+ });
142
+ const url = `https://fonts.googleapis.com/css2?${fontSpecs.join('&')}&display=swap`;
143
+ return `<link rel="preconnect" href="https://fonts.googleapis.com">
144
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
145
+ <link rel="stylesheet" href="${url}">`;
146
+ }
147
+ /**
148
+ * Generate a blocking script that prevents theme flash on page load.
149
+ * This script should be placed in the <head> of your HTML before any other scripts.
150
+ *
151
+ * The script:
152
+ * 1. Reads the stored theme from localStorage
153
+ * 2. Applies CSS variables immediately (before paint)
154
+ * 3. Optionally adds a 'no-transitions' class to prevent transition animations during hydration
155
+ * 4. Optionally preloads fonts for the current theme
156
+ */
157
+ export function generateBlockingScript(config) {
158
+ const { themes, storageKey = 'svelte-theme-picker', defaultTheme, cssVarPrefix = '', preventTransitions = true, } = config;
159
+ // Determine the actual default theme
160
+ const themeIds = Object.keys(themes);
161
+ const fallbackDefault = defaultTheme && themes[defaultTheme] ? defaultTheme : themeIds[0];
162
+ // Serialize themes for the blocking script (only what's needed: colors, fonts, effects)
163
+ const minimalThemes = {};
164
+ for (const [id, theme] of Object.entries(themes)) {
165
+ minimalThemes[id] = {
166
+ colors: theme.colors,
167
+ fonts: theme.fonts,
168
+ effects: theme.effects,
169
+ };
170
+ }
171
+ const themesJson = JSON.stringify(minimalThemes);
172
+ const prefix = cssVarPrefix ? `${cssVarPrefix}-` : '';
173
+ // Generate the blocking script
174
+ const script = `(function(){
175
+ var T=${themesJson};
176
+ var k="${storageKey}";
177
+ var d="${fallbackDefault}";
178
+ var p="${prefix}";
179
+ var r=document.documentElement;
180
+ ${preventTransitions ? 'r.classList.add("no-transitions");' : ''}
181
+ try{var s=localStorage.getItem(k);if(!s||!T[s])s=d;}catch(e){s=d;}
182
+ var t=T[s];if(t){
183
+ var c=t.colors,f=t.fonts,e=t.effects;
184
+ r.style.setProperty("--"+p+"bg-deep",c.bgDeep);
185
+ r.style.setProperty("--"+p+"bg-mid",c.bgMid);
186
+ r.style.setProperty("--"+p+"bg-card",c.bgCard);
187
+ r.style.setProperty("--"+p+"bg-glow",c.bgGlow);
188
+ r.style.setProperty("--"+p+"bg-overlay",c.bgOverlay);
189
+ r.style.setProperty("--"+p+"primary-1",c.primary1);
190
+ r.style.setProperty("--"+p+"primary-2",c.primary2);
191
+ r.style.setProperty("--"+p+"primary-3",c.primary3);
192
+ r.style.setProperty("--"+p+"primary-4",c.primary4);
193
+ r.style.setProperty("--"+p+"primary-5",c.primary5);
194
+ r.style.setProperty("--"+p+"primary-6",c.primary6);
195
+ r.style.setProperty("--"+p+"accent-1",c.accent1);
196
+ r.style.setProperty("--"+p+"accent-2",c.accent2);
197
+ r.style.setProperty("--"+p+"accent-3",c.accent3);
198
+ r.style.setProperty("--"+p+"text-primary",c.textPrimary);
199
+ r.style.setProperty("--"+p+"text-secondary",c.textSecondary);
200
+ r.style.setProperty("--"+p+"text-muted",c.textMuted);
201
+ r.style.setProperty("--"+p+"font-heading",f.heading);
202
+ r.style.setProperty("--"+p+"font-body",f.body);
203
+ r.style.setProperty("--"+p+"font-mono",f.mono);
204
+ r.style.setProperty("--"+p+"shadow-glow","0 0 40px "+e.glowColor);
205
+ r.style.setProperty("--"+p+"glow-color",e.glowColor);
206
+ r.style.setProperty("--"+p+"glow-intensity",e.glowIntensity);
207
+ r.setAttribute("data-theme",s);
208
+ }})();`;
209
+ return script;
210
+ }
211
+ /**
212
+ * Generate a complete SSR script tag that can be inserted into HTML.
213
+ * Includes the blocking script and optionally font preload links.
214
+ */
215
+ export function generateSSRHead(config) {
216
+ const { themes, fonts } = config;
217
+ const parts = [];
218
+ // Add font preload links if configured
219
+ if (fonts) {
220
+ // Get all unique fonts from all themes
221
+ const allFonts = new Set();
222
+ for (const theme of Object.values(themes)) {
223
+ for (const font of extractFonts(theme)) {
224
+ allFonts.add(font);
225
+ }
226
+ }
227
+ const fontLinks = generateFontPreloadLinks([...allFonts], fonts);
228
+ if (fontLinks) {
229
+ parts.push(fontLinks);
230
+ }
231
+ }
232
+ // Add the blocking script
233
+ parts.push(`<script>${generateBlockingScript(config)}</script>`);
234
+ // Add no-transitions CSS if enabled
235
+ if (config.preventTransitions !== false) {
236
+ parts.push(`<style>.no-transitions,.no-transitions *,.no-transitions *::before,.no-transitions *::after{transition:none!important;animation:none!important}</style>`);
237
+ }
238
+ return parts.join('\n');
239
+ }
package/dist/store.d.ts CHANGED
@@ -20,6 +20,8 @@ export interface ThemeStore extends Writable<string> {
20
20
  revertPreview: () => void;
21
21
  /** Check if currently in preview mode */
22
22
  isPreviewMode: () => boolean;
23
+ /** Destroy the store and clean up any listeners (e.g., cross-tab sync) */
24
+ destroy: () => void;
23
25
  }
24
26
  /**
25
27
  * Create a theme store with the given configuration
package/dist/store.js CHANGED
@@ -5,7 +5,7 @@ const isBrowser = typeof window !== 'undefined';
5
5
  * Create a theme store with the given configuration
6
6
  */
7
7
  export function createThemeStore(config = {}) {
8
- const { storageKey = 'svelte-theme-picker', defaultTheme = DEFAULT_THEME_ID, themes = defaultThemes, } = config;
8
+ const { storageKey = 'svelte-theme-picker', defaultTheme = DEFAULT_THEME_ID, themes = defaultThemes, syncTabs = false, } = config;
9
9
  function getInitialTheme() {
10
10
  if (isBrowser) {
11
11
  try {
@@ -66,7 +66,29 @@ export function createThemeStore(config = {}) {
66
66
  }
67
67
  },
68
68
  isPreviewMode: () => previewMode,
69
+ destroy: () => {
70
+ if (storageEventHandler) {
71
+ window.removeEventListener('storage', storageEventHandler);
72
+ storageEventHandler = null;
73
+ }
74
+ },
69
75
  };
76
+ // Cross-tab synchronization via storage events
77
+ let storageEventHandler = null;
78
+ if (syncTabs && isBrowser) {
79
+ storageEventHandler = (event) => {
80
+ if (event.key === storageKey && event.newValue) {
81
+ const newThemeId = event.newValue;
82
+ if (themes[newThemeId]) {
83
+ persistedThemeId = newThemeId;
84
+ currentThemeId = newThemeId;
85
+ previewMode = false;
86
+ set(newThemeId);
87
+ }
88
+ }
89
+ };
90
+ window.addEventListener('storage', storageEventHandler);
91
+ }
70
92
  return store;
71
93
  }
72
94
  /**
package/dist/themes.js CHANGED
@@ -4,6 +4,7 @@
4
4
  export const dreamy = {
5
5
  name: 'Dreamy',
6
6
  description: 'Soft pastels with dreamy atmosphere',
7
+ mode: 'dark',
7
8
  colors: {
8
9
  bgDeep: '#1a1a2e',
9
10
  bgMid: '#232342',
@@ -42,6 +43,7 @@ export const dreamy = {
42
43
  export const cyberpunk = {
43
44
  name: 'Cyberpunk',
44
45
  description: 'High contrast neons against dark backgrounds',
46
+ mode: 'dark',
45
47
  colors: {
46
48
  bgDeep: '#0a0a0f',
47
49
  bgMid: '#12121a',
@@ -80,6 +82,7 @@ export const cyberpunk = {
80
82
  export const sunset = {
81
83
  name: 'Sunset',
82
84
  description: 'Warm oranges and purples like a summer sunset',
85
+ mode: 'dark',
83
86
  colors: {
84
87
  bgDeep: '#1a1020',
85
88
  bgMid: '#2a1830',
@@ -118,6 +121,7 @@ export const sunset = {
118
121
  export const ocean = {
119
122
  name: 'Ocean',
120
123
  description: 'Deep blues and teals with bioluminescent accents',
124
+ mode: 'dark',
121
125
  colors: {
122
126
  bgDeep: '#0a1628',
123
127
  bgMid: '#0f2035',
@@ -156,6 +160,7 @@ export const ocean = {
156
160
  export const mono = {
157
161
  name: 'Mono',
158
162
  description: 'Clean monochromatic design with subtle accents',
163
+ mode: 'dark',
159
164
  colors: {
160
165
  bgDeep: '#111111',
161
166
  bgMid: '#1a1a1a',
@@ -194,6 +199,7 @@ export const mono = {
194
199
  export const sakura = {
195
200
  name: 'Sakura',
196
201
  description: 'Delicate cherry blossom pinks with spring greens',
202
+ mode: 'dark',
197
203
  colors: {
198
204
  bgDeep: '#1a1520',
199
205
  bgMid: '#251d28',
@@ -232,6 +238,7 @@ export const sakura = {
232
238
  export const aurora = {
233
239
  name: 'Aurora',
234
240
  description: 'Mystical aurora borealis dancing across the sky',
241
+ mode: 'dark',
235
242
  colors: {
236
243
  bgDeep: '#0a0f1a',
237
244
  bgMid: '#0f1725',
@@ -270,6 +277,7 @@ export const aurora = {
270
277
  export const galaxy = {
271
278
  name: 'Galaxy',
272
279
  description: 'Deep space with distant stars and cosmic nebulae',
280
+ mode: 'dark',
273
281
  colors: {
274
282
  bgDeep: '#05050f',
275
283
  bgMid: '#0a0a1a',
@@ -308,6 +316,7 @@ export const galaxy = {
308
316
  export const milk = {
309
317
  name: 'Milk',
310
318
  description: 'Clean and creamy whites with warm neutral accents',
319
+ mode: 'light',
311
320
  colors: {
312
321
  bgDeep: '#fefefe',
313
322
  bgMid: '#faf9f7',
@@ -346,6 +355,7 @@ export const milk = {
346
355
  export const light = {
347
356
  name: 'Light',
348
357
  description: 'A crisp, modern light theme with purple accents',
358
+ mode: 'light',
349
359
  colors: {
350
360
  bgDeep: '#ffffff',
351
361
  bgMid: '#f8f9fa',
package/dist/types.d.ts CHANGED
@@ -70,6 +70,8 @@ export interface Theme {
70
70
  fonts: ThemeFonts;
71
71
  /** Effect configuration */
72
72
  effects: ThemeEffects;
73
+ /** Theme mode for styling adjustments (affects picker UI, shadows, contrast) */
74
+ mode?: 'light' | 'dark';
73
75
  }
74
76
  /**
75
77
  * Configuration options for the theme picker
@@ -83,6 +85,8 @@ export interface ThemePickerConfig {
83
85
  themes?: Record<string, Theme>;
84
86
  /** CSS variable prefix (default: none, uses standard names) */
85
87
  cssVarPrefix?: string;
88
+ /** Enable cross-tab synchronization via storage events (default: false) */
89
+ syncTabs?: boolean;
86
90
  }
87
91
  /**
88
92
  * Metadata for a theme in a catalog
@@ -126,3 +130,46 @@ export interface ThemeFilterOptions {
126
130
  /** Exclude themes with ANY of these tags */
127
131
  excludeTags?: string[];
128
132
  }
133
+ /**
134
+ * Configuration for SSR blocking script generation
135
+ */
136
+ export interface SSRConfig {
137
+ /** All available themes */
138
+ themes: Record<string, Theme>;
139
+ /** localStorage key for theme persistence (default: 'svelte-theme-picker-theme') */
140
+ storageKey?: string;
141
+ /** Default theme ID if none is stored */
142
+ defaultTheme?: string;
143
+ /** CSS variable prefix (default: none) */
144
+ cssVarPrefix?: string;
145
+ /** Whether to add no-transitions class during hydration (default: true) */
146
+ preventTransitions?: boolean;
147
+ /** Font configuration for preloading */
148
+ fonts?: FontConfig;
149
+ }
150
+ /**
151
+ * Configuration for font preloading
152
+ */
153
+ export interface FontConfig {
154
+ /** Font provider: 'google', 'local', or 'custom' */
155
+ provider?: 'google' | 'local' | 'custom';
156
+ /** Font weights to preload per category */
157
+ weights?: {
158
+ heading?: number[];
159
+ body?: number[];
160
+ mono?: number[];
161
+ };
162
+ /** Custom font URL generator (for 'custom' provider) */
163
+ getFontUrl?: (fontName: string, weights: number[]) => string;
164
+ }
165
+ /**
166
+ * CSS variable schema mapping theme properties to CSS variable names
167
+ */
168
+ export interface ThemeSchema {
169
+ /** Color property to CSS variable name mapping */
170
+ colors: Record<keyof ThemeColors, string>;
171
+ /** Font property to CSS variable name mapping */
172
+ fonts: Record<keyof ThemeFonts, string>;
173
+ /** Effect property to CSS variable name mapping */
174
+ effects: Record<string, string>;
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-theme-picker",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A beautiful, customizable theme picker component for Svelte applications",
5
5
  "license": "MIT",
6
6
  "type": "module",