@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.
- package/dist/themes/ThemeProvider.svelte +99 -12
- package/dist/themes/ThemeProvider.svelte.d.ts +6 -0
- package/dist/themes/ThemeProvider.svelte.d.ts.map +1 -1
- package/dist/themes/__tests__/theme-provider.test.js +109 -0
- package/dist/themes/__tests__/theme-script.test.js +121 -0
- package/dist/themes/index.d.ts +1 -0
- package/dist/themes/index.d.ts.map +1 -1
- package/dist/themes/index.js +2 -0
- package/dist/themes/theme-script.d.ts +43 -0
- package/dist/themes/theme-script.d.ts.map +1 -0
- package/dist/themes/theme-script.js +68 -0
- package/package.json +2 -2
|
@@ -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]) =>
|
|
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
|
|
163
|
-
|
|
164
|
-
if (
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
286
|
+
if (preset !== prevPreset) {
|
|
287
|
+
prevPreset = preset;
|
|
288
|
+
untrack(() => {
|
|
289
|
+
config.preset = preset;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
231
292
|
});
|
|
232
293
|
|
|
233
294
|
$effect(() => {
|
|
234
|
-
if (
|
|
235
|
-
|
|
295
|
+
if (colorScheme !== prevColorScheme) {
|
|
296
|
+
prevColorScheme = colorScheme;
|
|
297
|
+
untrack(() => {
|
|
298
|
+
config.colorScheme = colorScheme;
|
|
299
|
+
});
|
|
236
300
|
}
|
|
237
301
|
});
|
|
238
302
|
|
|
239
303
|
$effect(() => {
|
|
240
|
-
|
|
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;
|
|
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
|
+
});
|
package/dist/themes/index.d.ts
CHANGED
|
@@ -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"}
|
package/dist/themes/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
127
|
+
"@happyvertical/smrt-types": "0.40.28"
|
|
128
128
|
},
|
|
129
129
|
"peerDependencies": {
|
|
130
130
|
"svelte": "^5.56.4"
|