@happyvertical/smrt-ui 0.40.26 → 0.40.28

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.
@@ -3,7 +3,7 @@ import type { Snippet } from 'svelte';
3
3
  import { onMount, untrack } from 'svelte';
4
4
  import { setThemeContext, type ThemeContext } from './context.svelte.js';
5
5
  import { generateThemeVariables } from './css-generator.js';
6
- import { getTheme } from './registry.js';
6
+ import { getTheme, isValidPreset } from './registry.js';
7
7
  import type {
8
8
  ColorScheme,
9
9
  Theme,
@@ -24,6 +24,12 @@ interface Props {
24
24
  borderRadius?: ThemeConfig['borderRadius'];
25
25
  /** Custom CSS variable overrides */
26
26
  overrides?: Record<string, string>;
27
+ /**
28
+ * Paint the theme's background/color/font onto the wrapper. Set false when
29
+ * the host app owns its surface palette and only wants the CSS variables +
30
+ * scheme context — avoids specificity-fighting `.smrt-theme-root`.
31
+ */
32
+ paintSurface?: boolean;
27
33
  /** Persist preferences to localStorage */
28
34
  persist?: boolean;
29
35
  /** Storage key for persistence */
@@ -38,6 +44,7 @@ let {
38
44
  primaryColor,
39
45
  borderRadius = defaultThemeConfig.borderRadius,
40
46
  overrides = {},
47
+ paintSurface = true,
41
48
  persist = defaultThemeConfig.persist,
42
49
  storageKey = defaultThemeConfig.storageKey,
43
50
  children,
@@ -101,7 +108,15 @@ const cssVariables = $derived.by(() => {
101
108
  // Convert to style string
102
109
  const styleString = $derived.by(() => {
103
110
  return Object.entries(cssVariables)
104
- .map(([key, value]) => `${key}: ${value}`)
111
+ .map(([key, value]) => {
112
+ const isExplicitOverride =
113
+ Object.hasOwn(config.overrides ?? {}, key) ||
114
+ (key === '--smrt-color-primary' && config.primaryColor);
115
+ const bootstrapKey = key.replace(/^--smrt-/, '--smrt-bootstrap-');
116
+ return `${key}: ${
117
+ isExplicitOverride ? value : `var(${bootstrapKey}, ${value})`
118
+ }`;
119
+ })
105
120
  .join('; ');
106
121
  });
107
122
 
@@ -158,10 +173,32 @@ function loadPersistedConfig(): void {
158
173
  try {
159
174
  const stored = localStorage.getItem(config.storageKey!);
160
175
  if (stored) {
161
- const data = JSON.parse(stored);
162
- if (data.preset) config.preset = data.preset;
163
- if (data.colorScheme) config.colorScheme = data.colorScheme;
164
- if (data.borderRadius) config.borderRadius = data.borderRadius;
176
+ const data = JSON.parse(stored) as unknown;
177
+ if (!data || typeof data !== 'object') return;
178
+ const persisted = data as Record<string, unknown>;
179
+ if (
180
+ typeof persisted.preset === 'string' &&
181
+ isValidPreset(persisted.preset)
182
+ ) {
183
+ config.preset = persisted.preset;
184
+ }
185
+ if (
186
+ persisted.colorScheme === 'light' ||
187
+ persisted.colorScheme === 'dark' ||
188
+ persisted.colorScheme === 'system'
189
+ ) {
190
+ config.colorScheme = persisted.colorScheme;
191
+ }
192
+ if (
193
+ persisted.borderRadius === 'none' ||
194
+ persisted.borderRadius === 'sm' ||
195
+ persisted.borderRadius === 'md' ||
196
+ persisted.borderRadius === 'lg' ||
197
+ persisted.borderRadius === 'xl' ||
198
+ persisted.borderRadius === 'full'
199
+ ) {
200
+ config.borderRadius = persisted.borderRadius;
201
+ }
165
202
  }
166
203
  } catch {
167
204
  // Ignore storage errors (e.g., corrupted data, JSON parse errors)
@@ -221,23 +258,66 @@ $effect(() => {
221
258
  }
222
259
  });
223
260
 
224
- // Sync props to state
261
+ // The pre-paint aliases only bridge SSR to the hydrated provider. Remove them
262
+ // after the provider has applied the persisted config so later runtime theme
263
+ // changes use the component's reactive variables normally.
225
264
  $effect(() => {
226
- config.preset = preset;
265
+ if (typeof document !== 'undefined' && mounted) {
266
+ const html = document.documentElement;
267
+ for (const property of Array.from(html.style)) {
268
+ if (property.startsWith('--smrt-bootstrap-')) {
269
+ html.style.removeProperty(property);
270
+ }
271
+ }
272
+ html.removeAttribute('data-smrt-theme-bootstrap');
273
+ }
227
274
  });
228
275
 
276
+ // Sync props to state — but only on actual prop changes. config is seeded
277
+ // from the initial props above and onMount applies persisted preferences, so
278
+ // re-asserting the initial prop values here would clobber the persisted
279
+ // choice ($effect order vs onMount is not a safe thing to rely on).
280
+ let prevPreset = untrack(() => preset);
281
+ let prevColorScheme = untrack(() => colorScheme);
282
+ let prevPrimaryColor = untrack(() => primaryColor);
283
+ let prevBorderRadius = untrack(() => borderRadius);
284
+
229
285
  $effect(() => {
230
- config.colorScheme = colorScheme;
286
+ if (preset !== prevPreset) {
287
+ prevPreset = preset;
288
+ untrack(() => {
289
+ config.preset = preset;
290
+ });
291
+ }
231
292
  });
232
293
 
233
294
  $effect(() => {
234
- if (primaryColor !== undefined) {
235
- config.primaryColor = primaryColor;
295
+ if (colorScheme !== prevColorScheme) {
296
+ prevColorScheme = colorScheme;
297
+ untrack(() => {
298
+ config.colorScheme = colorScheme;
299
+ });
236
300
  }
237
301
  });
238
302
 
239
303
  $effect(() => {
240
- config.borderRadius = borderRadius;
304
+ if (primaryColor !== prevPrimaryColor) {
305
+ prevPrimaryColor = primaryColor;
306
+ untrack(() => {
307
+ if (primaryColor !== undefined) {
308
+ config.primaryColor = primaryColor;
309
+ }
310
+ });
311
+ }
312
+ });
313
+
314
+ $effect(() => {
315
+ if (borderRadius !== prevBorderRadius) {
316
+ prevBorderRadius = borderRadius;
317
+ untrack(() => {
318
+ config.borderRadius = borderRadius;
319
+ });
320
+ }
241
321
  });
242
322
 
243
323
  // Sync overrides prop to state
@@ -257,6 +337,7 @@ $effect(() => {
257
337
  <div
258
338
  class="smrt-theme-root"
259
339
  class:dark={isDark}
340
+ class:no-paint={!paintSurface}
260
341
  class:smrt-theme-glass={config.preset === 'glass'}
261
342
  style={styleString}
262
343
  data-theme={config.preset}
@@ -275,6 +356,12 @@ $effect(() => {
275
356
  font-family: var(--smrt-font-family);
276
357
  }
277
358
 
359
+ .smrt-theme-root.no-paint {
360
+ color: inherit;
361
+ background-color: transparent;
362
+ font-family: inherit;
363
+ }
364
+
278
365
  /* Glass theme specific base styles */
279
366
  :global(.smrt-theme-glass) {
280
367
  --smrt-glass-surface: var(--smrt-color-surface);
@@ -11,6 +11,12 @@ interface Props {
11
11
  borderRadius?: ThemeConfig['borderRadius'];
12
12
  /** Custom CSS variable overrides */
13
13
  overrides?: Record<string, string>;
14
+ /**
15
+ * Paint the theme's background/color/font onto the wrapper. Set false when
16
+ * the host app owns its surface palette and only wants the CSS variables +
17
+ * scheme context — avoids specificity-fighting `.smrt-theme-root`.
18
+ */
19
+ paintSurface?: boolean;
14
20
  /** Persist preferences to localStorage */
15
21
  persist?: boolean;
16
22
  /** Storage key for persistence */
@@ -1 +1 @@
1
- {"version":3,"file":"ThemeProvider.svelte.d.ts","sourceRoot":"","sources":["../../src/themes/ThemeProvider.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAKtC,OAAO,KAAK,EACV,WAAW,EAEX,WAAW,EACX,WAAW,EAEZ,MAAM,YAAY,CAAC;AAIpB,UAAU,KAAK;IACb,0BAA0B;IAC1B,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,8BAA8B;IAC9B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,oCAAoC;IACpC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,+BAA+B;IAC/B,YAAY,CAAC,EAAE,WAAW,CAAC,cAAc,CAAC,CAAC;IAC3C,oCAAoC;IACpC,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,0CAA0C;IAC1C,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,kCAAkC;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc;IACd,QAAQ,EAAE,OAAO,CAAC;CACnB;AAiPD,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"ThemeProvider.svelte.d.ts","sourceRoot":"","sources":["../../src/themes/ThemeProvider.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAKtC,OAAO,KAAK,EACV,WAAW,EAEX,WAAW,EACX,WAAW,EAEZ,MAAM,YAAY,CAAC;AAIpB,UAAU,KAAK;IACb,0BAA0B;IAC1B,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,8BAA8B;IAC9B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,oCAAoC;IACpC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,+BAA+B;IAC/B,YAAY,CAAC,EAAE,WAAW,CAAC,cAAc,CAAC,CAAC;IAC3C,oCAAoC;IACpC,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,kCAAkC;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc;IACd,QAAQ,EAAE,OAAO,CAAC;CACnB;AA2TD,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Regression tests for ThemeProvider persistence behavior.
3
+ *
4
+ * Bug: the prop-sync `$effect`s re-asserted the `colorScheme`/`preset` props on
5
+ * every mount, clobbering the persisted localStorage preference loaded in
6
+ * `onMount` — a toggled light/dark choice snapped back to the prop (typically
7
+ * "system") on reload. Fixed by only syncing props on actual prop changes.
8
+ */
9
+ import { render, waitFor } from '@testing-library/svelte';
10
+ import { createRawSnippet } from 'svelte';
11
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
12
+ import ThemeProvider from '../ThemeProvider.svelte';
13
+ function child() {
14
+ return createRawSnippet(() => ({ render: () => '<span>child</span>' }));
15
+ }
16
+ function mockSystemDark(matches) {
17
+ window.matchMedia = vi.fn().mockImplementation((query) => ({
18
+ matches,
19
+ media: query,
20
+ addEventListener: vi.fn(),
21
+ removeEventListener: vi.fn(),
22
+ }));
23
+ }
24
+ describe('ThemeProvider persistence', () => {
25
+ beforeEach(() => {
26
+ localStorage.clear();
27
+ document.documentElement.removeAttribute('data-theme');
28
+ document.documentElement.removeAttribute('data-color-scheme');
29
+ });
30
+ it('keeps the persisted color scheme instead of clobbering it with the prop', async () => {
31
+ localStorage.setItem('smrt-theme', JSON.stringify({ colorScheme: 'light' }));
32
+ mockSystemDark(true); // system is dark; stored choice is light
33
+ render(ThemeProvider, {
34
+ props: { colorScheme: 'system', children: child() },
35
+ });
36
+ await waitFor(() => {
37
+ expect(document.documentElement.getAttribute('data-color-scheme')).toBe('light');
38
+ });
39
+ // The stored preference must survive too (not rewritten to the prop value)
40
+ expect(JSON.parse(localStorage.getItem('smrt-theme'))).toMatchObject({
41
+ colorScheme: 'light',
42
+ });
43
+ });
44
+ it('falls back to the system scheme when nothing is persisted', async () => {
45
+ mockSystemDark(true);
46
+ render(ThemeProvider, {
47
+ props: { colorScheme: 'system', children: child() },
48
+ });
49
+ await waitFor(() => {
50
+ expect(document.documentElement.getAttribute('data-color-scheme')).toBe('dark');
51
+ });
52
+ });
53
+ it('still applies prop changes after mount', async () => {
54
+ mockSystemDark(false);
55
+ const { rerender } = render(ThemeProvider, {
56
+ props: { colorScheme: 'system', children: child() },
57
+ });
58
+ await waitFor(() => {
59
+ expect(document.documentElement.getAttribute('data-color-scheme')).toBe('light');
60
+ });
61
+ await rerender({ colorScheme: 'dark', children: child() });
62
+ await waitFor(() => {
63
+ expect(document.documentElement.getAttribute('data-color-scheme')).toBe('dark');
64
+ });
65
+ });
66
+ it('paints the surface by default and opts out via paintSurface=false', async () => {
67
+ mockSystemDark(false);
68
+ const { container, rerender } = render(ThemeProvider, {
69
+ props: { children: child() },
70
+ });
71
+ const root = container.querySelector('.smrt-theme-root');
72
+ expect(root).not.toHaveClass('no-paint');
73
+ await rerender({ paintSurface: false, children: child() });
74
+ expect(root).toHaveClass('no-paint');
75
+ });
76
+ it('ignores invalid persisted theme values instead of breaking hydration', async () => {
77
+ localStorage.setItem('smrt-theme', JSON.stringify({
78
+ preset: 'not-a-theme',
79
+ colorScheme: 'sepia',
80
+ borderRadius: 'enormous',
81
+ }));
82
+ mockSystemDark(false);
83
+ render(ThemeProvider, {
84
+ props: {
85
+ preset: 'material',
86
+ colorScheme: 'system',
87
+ children: child(),
88
+ },
89
+ });
90
+ await waitFor(() => {
91
+ expect(document.documentElement.getAttribute('data-theme')).toBe('material');
92
+ expect(document.documentElement.getAttribute('data-color-scheme')).toBe('light');
93
+ });
94
+ });
95
+ it('emits bootstrap-aware SSR variable fallbacks', () => {
96
+ mockSystemDark(false);
97
+ const { container } = render(ThemeProvider, {
98
+ props: { children: child() },
99
+ });
100
+ const style = container.querySelector('.smrt-theme-root')?.getAttribute('style') ?? '';
101
+ const bootstrapBackground = [
102
+ '--smrt',
103
+ 'bootstrap',
104
+ 'color',
105
+ 'background',
106
+ ].join('-');
107
+ expect(style).toContain(`--smrt-color-background: var(${bootstrapBackground},`);
108
+ });
109
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Tests for the themeScript() pre-paint bootstrap generator. The generated
3
+ * script must mirror ThemeProvider's persistence resolution so apps stop
4
+ * hand-duplicating it in app.html.
5
+ */
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { createTheme, registerTheme } from '../create-theme.js';
8
+ import { generateThemeVariables } from '../css-generator.js';
9
+ import { studioTheme } from '../studio/index.js';
10
+ import { themeScript } from '../theme-script.js';
11
+ function mockSystemDark(matches) {
12
+ window.matchMedia = vi.fn().mockImplementation((query) => ({
13
+ matches,
14
+ media: query,
15
+ }));
16
+ }
17
+ function run(script) {
18
+ // biome-ignore lint/security/noGlobalEval: the browser evals this inline bootstrap; testing that is the point
19
+ (0, eval)(script);
20
+ }
21
+ describe('themeScript', () => {
22
+ beforeEach(() => {
23
+ vi.restoreAllMocks();
24
+ localStorage.clear();
25
+ document.documentElement.removeAttribute('data-theme');
26
+ document.documentElement.removeAttribute('data-color-scheme');
27
+ document.documentElement.removeAttribute('data-smrt-theme-bootstrap');
28
+ document.documentElement.classList.remove('dark');
29
+ for (const property of Array.from(document.documentElement.style)) {
30
+ if (property.startsWith('--smrt-bootstrap-')) {
31
+ document.documentElement.style.removeProperty(property);
32
+ }
33
+ }
34
+ });
35
+ it('resolves system preference when nothing is stored', () => {
36
+ mockSystemDark(true);
37
+ run(themeScript({ preset: 'material' }));
38
+ expect(document.documentElement.getAttribute('data-theme')).toBe('material');
39
+ expect(document.documentElement.getAttribute('data-color-scheme')).toBe('dark');
40
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
41
+ });
42
+ it('honours a stored scheme and preset over system and defaults', () => {
43
+ mockSystemDark(true);
44
+ localStorage.setItem('smrt-theme', JSON.stringify({ preset: 'studio', colorScheme: 'light' }));
45
+ run(themeScript({ preset: 'material' }));
46
+ expect(document.documentElement.getAttribute('data-theme')).toBe('studio');
47
+ expect(document.documentElement.getAttribute('data-color-scheme')).toBe('light');
48
+ expect(document.documentElement.classList.contains('dark')).toBe(false);
49
+ expect(document.documentElement.style.getPropertyValue('--smrt-bootstrap-color-background')).toBe(generateThemeVariables(studioTheme, false)['--smrt-color-background']);
50
+ });
51
+ it('uses the custom storageKey', () => {
52
+ mockSystemDark(false);
53
+ localStorage.setItem('my-theme', JSON.stringify({ colorScheme: 'dark' }));
54
+ run(themeScript({ storageKey: 'my-theme' }));
55
+ expect(document.documentElement.getAttribute('data-color-scheme')).toBe('dark');
56
+ });
57
+ it('does not read stored preferences when persistence is disabled', () => {
58
+ mockSystemDark(false);
59
+ localStorage.setItem('smrt-theme', JSON.stringify({ preset: 'studio', colorScheme: 'dark' }));
60
+ run(themeScript({
61
+ persist: false,
62
+ preset: 'material',
63
+ defaultColorScheme: 'light',
64
+ }));
65
+ expect(document.documentElement.getAttribute('data-theme')).toBe('material');
66
+ expect(document.documentElement.getAttribute('data-color-scheme')).toBe('light');
67
+ });
68
+ it('ignores a raw (non-JSON) stored value like ThemeProvider', () => {
69
+ mockSystemDark(true);
70
+ localStorage.setItem('smrt-theme', 'light');
71
+ run(themeScript());
72
+ expect(document.documentElement.getAttribute('data-color-scheme')).toBe('dark');
73
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
74
+ });
75
+ it('ignores invalid persisted enum values', () => {
76
+ mockSystemDark(false);
77
+ localStorage.setItem('smrt-theme', JSON.stringify({
78
+ preset: 'not-a-theme',
79
+ colorScheme: 'sepia',
80
+ }));
81
+ run(themeScript({ preset: 'material', defaultColorScheme: 'light' }));
82
+ expect(document.documentElement.getAttribute('data-theme')).toBe('material');
83
+ expect(document.documentElement.getAttribute('data-color-scheme')).toBe('light');
84
+ });
85
+ it('bootstraps a registered custom theme from persistence', () => {
86
+ const brandTheme = createTheme({
87
+ id: 'bootstrap-test-brand',
88
+ name: 'Bootstrap Test Brand',
89
+ light: { primary: '#336699', background: '#fefefe' },
90
+ dark: { primary: '#99ccff', background: '#101820' },
91
+ });
92
+ registerTheme(brandTheme);
93
+ mockSystemDark(false);
94
+ localStorage.setItem('smrt-theme', JSON.stringify({
95
+ preset: 'bootstrap-test-brand',
96
+ colorScheme: 'dark',
97
+ }));
98
+ run(themeScript());
99
+ expect(document.documentElement.getAttribute('data-theme')).toBe('bootstrap-test-brand');
100
+ expect(document.documentElement.style.getPropertyValue('--smrt-bootstrap-color-background')).toBe(generateThemeVariables(brandTheme, true)['--smrt-color-background']);
101
+ });
102
+ it('still applies the fallback when localStorage access throws', () => {
103
+ mockSystemDark(true);
104
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
105
+ throw new DOMException('denied', 'SecurityError');
106
+ });
107
+ run(themeScript({ preset: 'studio' }));
108
+ expect(document.documentElement.getAttribute('data-theme')).toBe('studio');
109
+ expect(document.documentElement.getAttribute('data-color-scheme')).toBe('dark');
110
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
111
+ });
112
+ it('escapes executable inline-script terminators and separators', () => {
113
+ const script = themeScript({
114
+ storageKey: '</script><script>alert(1)</script>\u2028\u2029',
115
+ });
116
+ expect(script.toLowerCase()).not.toContain('</script');
117
+ expect(script).not.toContain('\u2028');
118
+ expect(script).not.toContain('\u2029');
119
+ expect(script).toContain('\\u003c/script>');
120
+ });
121
+ });
@@ -27,6 +27,7 @@ export { appleEasing, borderRadiusScale, durationScale, materialEasing, spacingS
27
27
  export { smrtTheme } from './smrt/index.js';
28
28
  export { studioTheme } from './studio/index.js';
29
29
  export { default as ThemeProvider } from './ThemeProvider.svelte';
30
+ export { type ThemeScriptOptions, themeScript } from './theme-script.js';
30
31
  export type { BorderRadius, BorderRadiusScale, ColorPalette, ColorScheme, CSSVariableOptions, DurationScale, EasingScale, ElevationScale, GlassEffects, ResolvedScheme, SpacingScale, Theme, ThemeConfig, ThemeContext, ThemePreset, ThemeState, TypographyScale, TypographyToken, } from './types.js';
31
32
  export { defaultThemeConfig } from './types.js';
32
33
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/themes/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAGH,OAAO,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAEzE,OAAO,EACL,eAAe,EACf,eAAe,EACf,eAAe,EACf,kBAAkB,GACnB,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAE5D,OAAO,EACL,WAAW,EACX,oBAAoB,EACpB,kBAAkB,EAClB,iBAAiB,EACjB,oBAAoB,EACpB,aAAa,GACd,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EACL,oBAAoB,EACpB,gBAAgB,EAChB,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAE9C,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGpD,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,QAAQ,EACR,YAAY,EACZ,eAAe,EACf,aAAa,EACb,MAAM,GACP,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,WAAW,EACX,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,YAAY,EACZ,YAAY,GACb,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAElE,YAAY,EACV,YAAY,EACZ,iBAAiB,EACjB,YAAY,EACZ,WAAW,EACX,kBAAkB,EAClB,aAAa,EACb,WAAW,EACX,cAAc,EACd,YAAY,EACZ,cAAc,EACd,YAAY,EACZ,KAAK,EACL,WAAW,EACX,YAAY,EACZ,WAAW,EACX,UAAU,EACV,eAAe,EACf,eAAe,GAChB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/themes/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAGH,OAAO,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAEzE,OAAO,EACL,eAAe,EACf,eAAe,EACf,eAAe,EACf,kBAAkB,GACnB,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAE5D,OAAO,EACL,WAAW,EACX,oBAAoB,EACpB,kBAAkB,EAClB,iBAAiB,EACjB,oBAAoB,EACpB,aAAa,GACd,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EACL,oBAAoB,EACpB,gBAAgB,EAChB,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAE9C,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGpD,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,QAAQ,EACR,YAAY,EACZ,eAAe,EACf,aAAa,EACb,MAAM,GACP,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,WAAW,EACX,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,YAAY,EACZ,YAAY,GACb,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAElE,OAAO,EAAE,KAAK,kBAAkB,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEzE,YAAY,EACV,YAAY,EACZ,iBAAiB,EACjB,YAAY,EACZ,WAAW,EACX,kBAAkB,EAClB,aAAa,EACb,WAAW,EACX,cAAc,EACd,YAAY,EACZ,cAAc,EACd,YAAY,EACZ,KAAK,EACL,WAAW,EACX,YAAY,EACZ,WAAW,EACX,UAAU,EACV,eAAe,EACf,eAAe,GAChB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC"}
@@ -34,5 +34,7 @@ export { smrtTheme } from './smrt/index.js';
34
34
  export { studioTheme } from './studio/index.js';
35
35
  // Main components
36
36
  export { default as ThemeProvider } from './ThemeProvider.svelte';
37
+ // Pre-paint bootstrap script
38
+ export { themeScript } from './theme-script.js';
37
39
  // Constants
38
40
  export { defaultThemeConfig } from './types.js';
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Pre-paint theme bootstrap script generator.
3
+ *
4
+ * ThemeProvider resolves the persisted/system color scheme only after mount,
5
+ * so apps without a pre-paint script flash the wrong scheme on load. Apps used
6
+ * to hand-duplicate the provider's storage logic in `app.html`; this helper
7
+ * generates that script from the same config so the two never drift.
8
+ *
9
+ * Usage (root layout — SSR renders `svelte:head` into the initial HTML, so the
10
+ * script runs before first paint):
11
+ *
12
+ * ```svelte
13
+ * <script lang="ts">
14
+ * import { themeScript } from '@happyvertical/smrt-ui/themes';
15
+ * </script>
16
+ *
17
+ * <svelte:head>
18
+ * {@html `<script>${themeScript({ preset: 'studio' })}</script>`}
19
+ * </svelte:head>
20
+ * ```
21
+ */
22
+ import type { ColorScheme, ThemePreset } from './types.js';
23
+ export interface ThemeScriptOptions {
24
+ /** Preset to stamp as `data-theme` when nothing is persisted. */
25
+ preset?: ThemePreset;
26
+ /** Read persisted preferences. Must match ThemeProvider's `persist` prop. */
27
+ persist?: boolean;
28
+ /** localStorage key — must match ThemeProvider's `storageKey`. */
29
+ storageKey?: string;
30
+ /** Fallback scheme when storage holds no value. Default: 'system'. */
31
+ defaultColorScheme?: ColorScheme;
32
+ }
33
+ /**
34
+ * Returns the JS body (no `<script>` tags) of a pre-paint bootstrap that
35
+ * mirrors ThemeProvider's persistence: reads the stored config, resolves
36
+ * 'system' via matchMedia, then stamps `data-theme`, `data-color-scheme`,
37
+ * the `dark` class, and `color-scheme` style on <html>. It also sets temporary
38
+ * bootstrap variables consumed by ThemeProvider's SSR inline fallbacks, so the
39
+ * persisted preset/scheme wins before hydration rather than only changing
40
+ * attributes around an already-painted default wrapper.
41
+ */
42
+ export declare function themeScript(options?: ThemeScriptOptions): string;
43
+ //# sourceMappingURL=theme-script.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme-script.d.ts","sourceRoot":"","sources":["../../src/themes/theme-script.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAIH,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAG3D,MAAM,WAAW,kBAAkB;IACjC,iEAAiE;IACjE,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,6EAA6E;IAC7E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,kBAAkB,CAAC,EAAE,WAAW,CAAC;CAClC;AAaD;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,MAAM,CAoCpE"}
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Pre-paint theme bootstrap script generator.
3
+ *
4
+ * ThemeProvider resolves the persisted/system color scheme only after mount,
5
+ * so apps without a pre-paint script flash the wrong scheme on load. Apps used
6
+ * to hand-duplicate the provider's storage logic in `app.html`; this helper
7
+ * generates that script from the same config so the two never drift.
8
+ *
9
+ * Usage (root layout — SSR renders `svelte:head` into the initial HTML, so the
10
+ * script runs before first paint):
11
+ *
12
+ * ```svelte
13
+ * <script lang="ts">
14
+ * import { themeScript } from '@happyvertical/smrt-ui/themes';
15
+ * </script>
16
+ *
17
+ * <svelte:head>
18
+ * {@html `<script>${themeScript({ preset: 'studio' })}</script>`}
19
+ * </svelte:head>
20
+ * ```
21
+ */
22
+ import { generateThemeVariables } from './css-generator.js';
23
+ import { getTheme, getThemeOptions } from './registry.js';
24
+ import { defaultThemeConfig } from './types.js';
25
+ function inlineJSON(value) {
26
+ return JSON.stringify(value)
27
+ .replace(/</g, '\\u003c')
28
+ .replace(/\u2028/g, '\\u2028')
29
+ .replace(/\u2029/g, '\\u2029');
30
+ }
31
+ function bootstrapVariableName(variable) {
32
+ return variable.replace(/^--smrt-/, '--smrt-bootstrap-');
33
+ }
34
+ /**
35
+ * Returns the JS body (no `<script>` tags) of a pre-paint bootstrap that
36
+ * mirrors ThemeProvider's persistence: reads the stored config, resolves
37
+ * 'system' via matchMedia, then stamps `data-theme`, `data-color-scheme`,
38
+ * the `dark` class, and `color-scheme` style on <html>. It also sets temporary
39
+ * bootstrap variables consumed by ThemeProvider's SSR inline fallbacks, so the
40
+ * persisted preset/scheme wins before hydration rather than only changing
41
+ * attributes around an already-painted default wrapper.
42
+ */
43
+ export function themeScript(options = {}) {
44
+ const presetNames = getThemeOptions().map(({ value }) => value);
45
+ const requestedPreset = options.preset ?? defaultThemeConfig.preset;
46
+ const preset = presetNames.includes(requestedPreset)
47
+ ? requestedPreset
48
+ : defaultThemeConfig.preset;
49
+ const persist = options.persist ?? defaultThemeConfig.persist;
50
+ const storageKey = options.storageKey ?? defaultThemeConfig.storageKey;
51
+ const requestedFallback = options.defaultColorScheme ?? defaultThemeConfig.colorScheme;
52
+ const fallback = ['light', 'dark', 'system'].includes(requestedFallback)
53
+ ? requestedFallback
54
+ : defaultThemeConfig.colorScheme;
55
+ const variableNames = Array.from(new Set(presetNames.flatMap((name) => [
56
+ ...Object.keys(generateThemeVariables(getTheme(name), false)),
57
+ ...Object.keys(generateThemeVariables(getTheme(name), true)),
58
+ ])));
59
+ const bootstrapNames = variableNames.map(bootstrapVariableName);
60
+ const variableRows = presetNames.flatMap((name) => [false, true].map((dark) => {
61
+ const variables = generateThemeVariables(getTheme(name), dark);
62
+ return variableNames.map((variable) => variables[variable] ?? '');
63
+ }));
64
+ // Keep this dependency-free and ES5-ish: it runs inline in every browser
65
+ // before any framework code. Must stay in sync with
66
+ // ThemeProvider.svelte's loadPersistedConfig()/resolvedScheme.
67
+ return `(function(){try{var presets=${inlineJSON(presetNames)};var names=${inlineJSON(bootstrapNames)};var rows=${inlineJSON(variableRows)};var scheme=${inlineJSON(fallback)};var preset=${inlineJSON(preset)};${persist ? `try{var raw=localStorage.getItem(${inlineJSON(storageKey)});if(raw){var data=JSON.parse(raw);if(data&&typeof data==='object'){if(data.colorScheme==='light'||data.colorScheme==='dark'||data.colorScheme==='system')scheme=data.colorScheme;if(typeof data.preset==='string'&&presets.indexOf(data.preset)!==-1)preset=data.preset;}}}catch(_){}` : ''}if(scheme==='system'){scheme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}var el=document.documentElement;el.setAttribute('data-theme',preset);el.setAttribute('data-color-scheme',scheme);el.setAttribute('data-smrt-theme-bootstrap','');el.classList.toggle('dark',scheme==='dark');el.style.colorScheme=scheme;var row=rows[presets.indexOf(preset)*2+(scheme==='dark'?1:0)];for(var i=0;i<names.length;i++){if(row[i])el.style.setProperty(names[i],row[i]);}}catch(_){}})();`;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-ui",
3
- "version": "0.40.26",
3
+ "version": "0.40.28",
4
4
  "description": "Domain-agnostic Svelte 5 UI runtime for SMRT: primitives, i18n client, theme system, and module UI registry",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -124,7 +124,7 @@
124
124
  },
125
125
  "dependencies": {
126
126
  "esm-env": "^1.2.2",
127
- "@happyvertical/smrt-types": "0.40.26"
127
+ "@happyvertical/smrt-types": "0.40.28"
128
128
  },
129
129
  "peerDependencies": {
130
130
  "svelte": "^5.56.4"