@shohojdhara/atomix 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/atomix.css +309 -105
  3. package/dist/atomix.min.css +3 -5
  4. package/dist/index.d.ts +807 -51
  5. package/dist/index.esm.js +16367 -16405
  6. package/dist/index.esm.js.map +1 -1
  7. package/dist/index.js +16277 -16330
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.min.js +1 -1
  10. package/dist/index.min.js.map +1 -1
  11. package/dist/themes/applemix.css +309 -105
  12. package/dist/themes/applemix.min.css +5 -7
  13. package/dist/themes/boomdevs.css +202 -10
  14. package/dist/themes/boomdevs.min.css +3 -5
  15. package/dist/themes/esrar.css +309 -105
  16. package/dist/themes/esrar.min.css +4 -6
  17. package/dist/themes/flashtrade.css +310 -105
  18. package/dist/themes/flashtrade.min.css +5 -7
  19. package/dist/themes/mashroom.css +300 -96
  20. package/dist/themes/mashroom.min.css +4 -6
  21. package/dist/themes/shaj-default.css +300 -96
  22. package/dist/themes/shaj-default.min.css +4 -6
  23. package/package.json +1 -1
  24. package/src/components/AtomixGlass/AtomixGlass.test.tsx +21 -32
  25. package/src/components/AtomixGlass/AtomixGlass.tsx +55 -42
  26. package/src/components/AtomixGlass/AtomixGlassContainer.tsx +205 -57
  27. package/src/components/AtomixGlass/GlassFilter.tsx +22 -8
  28. package/src/components/AtomixGlass/__snapshots__/AtomixGlass.test.tsx.snap +221 -0
  29. package/src/components/AtomixGlass/atomixGLass.old.tsx +0 -3
  30. package/src/components/AtomixGlass/shader-utils.ts +8 -0
  31. package/src/components/AtomixGlass/stories/AtomixGlass.stories.tsx +319 -100
  32. package/src/components/AtomixGlass/stories/Examples.stories.tsx +601 -105
  33. package/src/components/AtomixGlass/stories/Modes.stories.tsx +30 -12
  34. package/src/components/AtomixGlass/stories/Playground.stories.tsx +173 -38
  35. package/src/components/AtomixGlass/stories/ShaderVariants.stories.tsx +18 -18
  36. package/src/components/AtomixGlass/stories/shared-components.tsx +27 -5
  37. package/src/components/Breadcrumb/Breadcrumb.tsx +8 -3
  38. package/src/components/Button/Button.tsx +62 -17
  39. package/src/components/Callout/Callout.test.tsx +8 -14
  40. package/src/components/Card/Card.tsx +103 -1
  41. package/src/components/Card/index.ts +3 -2
  42. package/src/components/Footer/Footer.stories.tsx +1 -2
  43. package/src/components/Footer/Footer.tsx +0 -5
  44. package/src/components/Footer/FooterLink.tsx +3 -2
  45. package/src/components/Footer/FooterSection.tsx +0 -7
  46. package/src/components/Icon/index.ts +1 -1
  47. package/src/components/Modal/Modal.stories.tsx +29 -38
  48. package/src/components/Modal/Modal.tsx +4 -4
  49. package/src/components/Navigation/Nav/NavItem.tsx +8 -3
  50. package/src/components/Navigation/SideMenu/SideMenu.tsx +49 -41
  51. package/src/components/Navigation/SideMenu/SideMenuItem.tsx +63 -19
  52. package/src/components/Popover/Popover.tsx +1 -1
  53. package/src/components/VideoPlayer/VideoPlayer.stories.tsx +977 -400
  54. package/src/components/VideoPlayer/VideoPlayer.tsx +1 -6
  55. package/src/lib/composables/shared-mouse-tracker.ts +133 -0
  56. package/src/lib/composables/useAtomixGlass.ts +303 -115
  57. package/src/lib/theme/ThemeManager.integration.test.ts +124 -0
  58. package/src/lib/theme/ThemeManager.stories.tsx +13 -13
  59. package/src/lib/theme/ThemeManager.test.ts +4 -0
  60. package/src/lib/theme/ThemeManager.ts +203 -59
  61. package/src/lib/theme/ThemeProvider.tsx +183 -33
  62. package/src/lib/theme/composeTheme.ts +375 -0
  63. package/src/lib/theme/createTheme.test.ts +475 -0
  64. package/src/lib/theme/createTheme.ts +510 -0
  65. package/src/lib/theme/generateCSSVariables.ts +713 -0
  66. package/src/lib/theme/index.ts +67 -0
  67. package/src/lib/theme/themeUtils.ts +333 -0
  68. package/src/lib/theme/types.ts +337 -8
  69. package/src/lib/theme/useTheme.test.tsx +2 -1
  70. package/src/lib/theme/useTheme.ts +6 -22
  71. package/src/lib/types/components.ts +152 -57
  72. package/src/styles/01-settings/_index.scss +2 -2
  73. package/src/styles/01-settings/_settings.badge.scss +2 -2
  74. package/src/styles/01-settings/_settings.border-radius.scss +1 -1
  75. package/src/styles/01-settings/{_settings.maps.scss → _settings.design-tokens.scss} +163 -49
  76. package/src/styles/01-settings/_settings.modal.scss +1 -1
  77. package/src/styles/01-settings/_settings.spacing.scss +14 -13
  78. package/src/styles/03-generic/_generic.root.scss +131 -50
  79. package/src/styles/05-objects/_objects.block.scss +1 -1
  80. package/src/styles/06-components/_components.atomix-glass.scss +20 -22
  81. package/src/styles/06-components/_components.badge.scss +2 -2
  82. package/src/styles/06-components/_components.button.scss +1 -1
  83. package/src/styles/06-components/_components.callout.scss +1 -1
  84. package/src/styles/06-components/_components.card.scss +74 -2
  85. package/src/styles/06-components/_components.chart.scss +1 -1
  86. package/src/styles/06-components/_components.dropdown.scss +6 -0
  87. package/src/styles/06-components/_components.footer.scss +1 -1
  88. package/src/styles/06-components/_components.list-group.scss +1 -1
  89. package/src/styles/06-components/_components.list.scss +1 -1
  90. package/src/styles/06-components/_components.menu.scss +1 -1
  91. package/src/styles/06-components/_components.messages.scss +1 -1
  92. package/src/styles/06-components/_components.modal.scss +7 -2
  93. package/src/styles/06-components/_components.navbar.scss +1 -1
  94. package/src/styles/06-components/_components.popover.scss +10 -0
  95. package/src/styles/06-components/_components.product-review.scss +1 -1
  96. package/src/styles/06-components/_components.progress.scss +1 -1
  97. package/src/styles/06-components/_components.rating.scss +1 -1
  98. package/src/styles/06-components/_components.spinner.scss +1 -1
  99. package/src/styles/99-utilities/_utilities.background.scss +1 -1
  100. package/src/styles/99-utilities/_utilities.border.scss +1 -1
  101. package/src/styles/99-utilities/_utilities.link.scss +1 -1
  102. package/src/styles/99-utilities/_utilities.text.scss +1 -1
@@ -0,0 +1,124 @@
1
+ /**
2
+ * ThemeManager Integration Tests
3
+ *
4
+ * Verifies integration between ThemeManager and createTheme system
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+ import { ThemeManager } from './ThemeManager';
9
+ import { createTheme } from './createTheme';
10
+ import { generateCSSVariables, injectCSS, removeInjectedCSS } from './generateCSSVariables';
11
+ import * as utils from './utils';
12
+ import { isJSTheme } from './themeUtils';
13
+
14
+ // Mock generateCSSVariables module
15
+ vi.mock('./generateCSSVariables', () => ({
16
+ generateCSSVariables: vi.fn(() => '--mock-css: 1;'),
17
+ injectCSS: vi.fn(),
18
+ removeInjectedCSS: vi.fn(),
19
+ }));
20
+
21
+ // Mock utils
22
+ vi.mock('./utils', () => ({
23
+ isBrowser: vi.fn(() => true),
24
+ isServer: vi.fn(() => false),
25
+ loadThemeCSS: vi.fn(() => Promise.resolve()),
26
+ removeThemeCSS: vi.fn(),
27
+ removeAllThemeCSS: vi.fn(),
28
+ applyThemeAttributes: vi.fn(),
29
+ getCurrentThemeFromDOM: vi.fn(() => null),
30
+ getSystemTheme: vi.fn(() => 'light'),
31
+ isThemeLoaded: vi.fn(() => false),
32
+ validateThemeMetadata: vi.fn(() => ({ valid: true, errors: [], warnings: [] })),
33
+ isValidThemeName: vi.fn(() => true),
34
+ createLocalStorageAdapter: vi.fn(() => ({
35
+ getItem: vi.fn(),
36
+ setItem: vi.fn(),
37
+ removeItem: vi.fn(),
38
+ isAvailable: vi.fn(() => true),
39
+ })),
40
+ }));
41
+
42
+ describe('ThemeManager Integration with JS Themes', () => {
43
+ let themeManager: ThemeManager;
44
+
45
+ beforeEach(() => {
46
+ vi.clearAllMocks();
47
+ });
48
+
49
+ afterEach(() => {
50
+ if (themeManager) {
51
+ themeManager.destroy();
52
+ }
53
+ });
54
+
55
+ it('should allow setting a JS theme object directly', async () => {
56
+ themeManager = new ThemeManager({
57
+ defaultTheme: 'dummy',
58
+ themes: { dummy: { name: 'dummy' } },
59
+ });
60
+
61
+ const jsTheme = createTheme({
62
+ name: 'custom-js-theme',
63
+ palette: { primary: { main: '#ff0000' } }
64
+ });
65
+
66
+ await themeManager.setTheme(jsTheme);
67
+
68
+ expect(themeManager.getTheme()).toBe('custom-js-theme');
69
+ expect(themeManager.getActiveTheme()).toBe(jsTheme);
70
+ expect(generateCSSVariables).toHaveBeenCalledWith(jsTheme);
71
+ expect(injectCSS).toHaveBeenCalled();
72
+ expect(utils.applyThemeAttributes).toHaveBeenCalledWith('custom-js-theme', 'data-theme');
73
+ });
74
+
75
+ it('should initialize with a JS theme as defaultTheme', () => {
76
+ const jsTheme = createTheme({
77
+ name: 'default-js-theme',
78
+ palette: { primary: { main: '#00ff00' } }
79
+ });
80
+
81
+ // Constructor shouldn't throw if defaultTheme is object, even if themes is empty
82
+ themeManager = new ThemeManager({
83
+ themes: {},
84
+ defaultTheme: jsTheme,
85
+ });
86
+
87
+ expect(themeManager.getTheme()).toBe('default-js-theme');
88
+ expect(themeManager.getActiveTheme()).toBe(jsTheme);
89
+ // It initializes synchronously
90
+ expect(generateCSSVariables).toHaveBeenCalledWith(jsTheme);
91
+ expect(injectCSS).toHaveBeenCalled();
92
+ });
93
+
94
+ it('should switch between JS and CSS themes', async () => {
95
+ const jsTheme = createTheme({ name: 'js-theme' });
96
+
97
+ themeManager = new ThemeManager({
98
+ themes: {
99
+ 'css-theme': { name: 'CSS Theme' },
100
+ },
101
+ defaultTheme: 'css-theme',
102
+ });
103
+
104
+ // Initial state: CSS theme
105
+ expect(themeManager.getTheme()).toBe('css-theme');
106
+ expect(themeManager.getActiveTheme()).toBeNull();
107
+
108
+ // Switch to JS theme
109
+ await themeManager.setTheme(jsTheme, { removePrevious: true });
110
+
111
+ expect(themeManager.getTheme()).toBe('js-theme');
112
+ expect(themeManager.getActiveTheme()).toBe(jsTheme);
113
+ expect(injectCSS).toHaveBeenCalled();
114
+ expect(utils.removeThemeCSS).toHaveBeenCalledWith('css-theme');
115
+
116
+ // Switch back to CSS theme
117
+ await themeManager.setTheme('css-theme', { removePrevious: true });
118
+
119
+ expect(themeManager.getTheme()).toBe('css-theme');
120
+ expect(themeManager.getActiveTheme()).toBeNull();
121
+ expect(removeInjectedCSS).toHaveBeenCalled();
122
+ expect(utils.loadThemeCSS).toHaveBeenCalledWith('css-theme', '/themes', false, null);
123
+ });
124
+ });
@@ -1,10 +1,10 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react';
2
2
  import React, { useState } from 'react';
3
- import { ThemeProvider, useTheme } from './index';
4
- import { themesConfig } from '@/themes/themes.config';
5
- import { Button } from '@/components/Button/Button';
6
- import { Card } from '@/components/Card/Card';
7
- import { ColorModeToggle } from '@/components/ColorModeToggle/ColorModeToggle';
3
+ import { ThemeMetadata, ThemeProvider, useTheme } from './index';
4
+ import { themesConfig } from '../../themes/themes.config';
5
+ import { Button } from '../../components/Button/Button';
6
+ import { Card } from '../../components/Card/Card';
7
+ import { ColorModeToggle } from '../../components/ColorModeToggle/ColorModeToggle';
8
8
 
9
9
  /**
10
10
  * Theme Manager
@@ -111,7 +111,7 @@ function ThemeInfo() {
111
111
  export const BasicThemeSwitching: Story = {
112
112
  render: () => (
113
113
  <ThemeProvider
114
- themes={themesConfig.metadata}
114
+ themes={themesConfig.metadata as Record<string, ThemeMetadata>}
115
115
  defaultTheme="shaj-default"
116
116
  enablePersistence={false}
117
117
  >
@@ -133,7 +133,7 @@ export const BasicThemeSwitching: Story = {
133
133
  export const WithPersistence: Story = {
134
134
  render: () => (
135
135
  <ThemeProvider
136
- themes={themesConfig.metadata}
136
+ themes={themesConfig.metadata as Record<string, ThemeMetadata>}
137
137
  defaultTheme="shaj-default"
138
138
  enablePersistence={true}
139
139
  storageKey="storybook-theme-demo"
@@ -161,7 +161,7 @@ export const WithPersistence: Story = {
161
161
  export const ThemeInfoDisplay: Story = {
162
162
  render: () => (
163
163
  <ThemeProvider
164
- themes={themesConfig.metadata}
164
+ themes={themesConfig.metadata as Record<string, ThemeMetadata>}
165
165
  defaultTheme="shaj-default"
166
166
  >
167
167
  <div style={{ padding: '2rem', display: 'grid', gap: '1.5rem' }}>
@@ -237,7 +237,7 @@ export const WithPreloading: Story = {
237
237
 
238
238
  return (
239
239
  <ThemeProvider
240
- themes={themesConfig.metadata}
240
+ themes={themesConfig.metadata as Record<string, ThemeMetadata>}
241
241
  defaultTheme="shaj-default"
242
242
  preload={['shaj-default']}
243
243
  >
@@ -257,7 +257,7 @@ export const WithPreloading: Story = {
257
257
  export const WithColorModeToggle: Story = {
258
258
  render: () => (
259
259
  <ThemeProvider
260
- themes={themesConfig.metadata}
260
+ themes={themesConfig.metadata as Record<string, ThemeMetadata>}
261
261
  defaultTheme="shaj-default"
262
262
  >
263
263
  <div style={{ padding: '2rem' }}>
@@ -289,7 +289,7 @@ export const WithColorModeToggle: Story = {
289
289
  export const ComponentShowcase: Story = {
290
290
  render: () => (
291
291
  <ThemeProvider
292
- themes={themesConfig.metadata}
292
+ themes={themesConfig.metadata as Record<string, ThemeMetadata>}
293
293
  defaultTheme="shaj-default"
294
294
  >
295
295
  <div style={{ padding: '2rem', display: 'grid', gap: '1.5rem' }}>
@@ -391,7 +391,7 @@ export const ErrorHandling: Story = {
391
391
 
392
392
  return (
393
393
  <ThemeProvider
394
- themes={themesConfig.metadata}
394
+ themes={themesConfig.metadata as Record<string, ThemeMetadata>}
395
395
  defaultTheme="shaj-default"
396
396
  onError={(error, themeName) => {
397
397
  console.error(`Failed to load theme "${themeName}":`, error);
@@ -421,7 +421,7 @@ export const CustomCallbacks: Story = {
421
421
 
422
422
  return (
423
423
  <ThemeProvider
424
- themes={themesConfig.metadata}
424
+ themes={themesConfig.metadata as Record<string, ThemeMetadata>}
425
425
  defaultTheme="shaj-default"
426
426
  onThemeChange={(theme) => {
427
427
  addLog(`Theme changed to: ${theme}`);
@@ -90,6 +90,10 @@ describe('ThemeManager', () => {
90
90
  });
91
91
 
92
92
  it('should not reload if theme is already active', async () => {
93
+ // Wait for init to potentially settle
94
+ await new Promise(resolve => setTimeout(resolve, 0));
95
+ vi.clearAllMocks();
96
+
93
97
  await themeManager.setTheme('theme-1');
94
98
  expect(utils.loadThemeCSS).not.toHaveBeenCalled();
95
99
  });
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Core theme management class for the Atomix Design System.
5
5
  * Handles theme loading, switching, persistence, and events.
6
+ * Supports both CSS-based themes and JavaScript-based themes.
6
7
  */
7
8
 
8
9
  import type {
@@ -15,6 +16,7 @@ import type {
15
16
  ThemeLoadCallback,
16
17
  ThemeErrorCallback,
17
18
  StorageAdapter,
19
+ Theme,
18
20
  } from './types';
19
21
 
20
22
  import {
@@ -31,6 +33,9 @@ import {
31
33
  createLocalStorageAdapter,
32
34
  } from './utils';
33
35
 
36
+ import { isJSTheme } from './themeUtils';
37
+ import { generateCSSVariables, injectCSS, removeInjectedCSS } from './generateCSSVariables';
38
+
34
39
  /**
35
40
  * Default configuration values
36
41
  */
@@ -45,6 +50,9 @@ const DEFAULT_CONFIG: Partial<ThemeManagerConfig> = {
45
50
  preload: [],
46
51
  };
47
52
 
53
+ // ID for injected JS theme styles
54
+ const JS_THEME_STYLE_ID = 'atomix-js-theme-styles';
55
+
48
56
  /**
49
57
  * ThemeManager class
50
58
  *
@@ -61,13 +69,15 @@ const DEFAULT_CONFIG: Partial<ThemeManagerConfig> = {
61
69
  * ```
62
70
  */
63
71
  export class ThemeManager {
64
- private config: Required<Omit<ThemeManagerConfig, 'onThemeChange' | 'onError' | 'cdnPath'>> & {
72
+ private config: Required<Omit<ThemeManagerConfig, 'onThemeChange' | 'onError' | 'cdnPath' | 'defaultTheme'>> & {
73
+ defaultTheme?: string | Theme;
65
74
  cdnPath: string | null;
66
- onThemeChange?: (theme: string) => void;
75
+ onThemeChange?: (theme: string | Theme) => void;
67
76
  onError?: (error: Error, themeName: string) => void;
68
77
  };
69
78
 
70
79
  private currentTheme: string | null = null;
80
+ private activeTheme: Theme | null = null;
71
81
  private loadedThemes: Set<string> = new Set();
72
82
  private loadingThemes: Map<string, Promise<void>> = new Map();
73
83
  private eventListeners: ThemeEventListeners = {
@@ -85,7 +95,11 @@ export class ThemeManager {
85
95
  */
86
96
  constructor(config: ThemeManagerConfig) {
87
97
  // Validate required config
88
- if (!config.themes || Object.keys(config.themes).length === 0) {
98
+ const hasThemes = config.themes && Object.keys(config.themes).length > 0;
99
+ const hasDefaultThemeObject = config.defaultTheme && typeof config.defaultTheme !== 'string';
100
+
101
+ if (!hasThemes && !hasDefaultThemeObject) {
102
+ // For backward compatibility: require themes if no JS theme object is provided
89
103
  throw new Error('ThemeManager: themes configuration is required');
90
104
  }
91
105
 
@@ -93,17 +107,21 @@ export class ThemeManager {
93
107
  this.config = {
94
108
  ...DEFAULT_CONFIG,
95
109
  ...config,
96
- themes: config.themes,
97
- defaultTheme: config.defaultTheme || Object.keys(config.themes)[0],
98
- } as Required<Omit<ThemeManagerConfig, 'onThemeChange' | 'onError' | 'cdnPath'>> & {
99
- cdnPath: string | null;
100
- onThemeChange?: (theme: string) => void;
101
- onError?: (error: Error, themeName: string) => void;
102
- };
110
+ themes: config.themes || {},
111
+ defaultTheme: config.defaultTheme || (config.themes && Object.keys(config.themes)[0]),
112
+ } as any;
103
113
 
104
- // Validate default theme exists
105
- if (!this.config.themes[this.config.defaultTheme]) {
106
- throw new Error(`ThemeManager: default theme "${this.config.defaultTheme}" not found in themes configuration`);
114
+ // Default theme required if provided
115
+ if (!this.config.defaultTheme) {
116
+ console.warn('ThemeManager: No default theme provided.');
117
+ }
118
+
119
+ // Validate default theme exists (if string)
120
+ if (typeof this.config.defaultTheme === 'string') {
121
+ const defName = this.config.defaultTheme;
122
+ if (!this.config.themes[defName]) {
123
+ throw new Error(`ThemeManager: default theme "${defName}" not found in themes configuration`);
124
+ }
107
125
  }
108
126
 
109
127
  // Initialize storage adapter
@@ -126,19 +144,53 @@ export class ThemeManager {
126
144
 
127
145
  if (this.config.enablePersistence && this.storageAdapter.isAvailable()) {
128
146
  const storedTheme = this.storageAdapter.getItem(this.config.storageKey);
129
- if (storedTheme && this.config.themes[storedTheme]) {
130
- initialTheme = storedTheme;
147
+ if (storedTheme) {
148
+ // If stored theme is a name in config.themes, use it
149
+ if (typeof storedTheme === 'string' && this.config.themes[storedTheme]) {
150
+ initialTheme = storedTheme;
151
+ } else if (typeof storedTheme === 'string') {
152
+ // Check if stored theme name matches a JS theme (defaultTheme as Theme object)
153
+ // This handles persistence of JS themes that aren't in config.themes
154
+ if (isJSTheme(this.config.defaultTheme)) {
155
+ const defaultThemeName = this.config.defaultTheme.name || 'custom-theme';
156
+ if (storedTheme === defaultThemeName) {
157
+ initialTheme = this.config.defaultTheme;
158
+ }
159
+ }
160
+ }
131
161
  }
132
162
  }
133
163
 
134
- // Check if theme is already set in DOM
164
+ // Check if theme is already set in DOM (CSS themes)
135
165
  const domTheme = getCurrentThemeFromDOM(this.config.dataAttribute);
136
166
  if (domTheme && this.config.themes[domTheme]) {
137
167
  initialTheme = domTheme;
138
168
  }
139
169
 
140
- // Set initial theme
141
- this.currentTheme = initialTheme;
170
+ // Set initial theme synchronously
171
+ if (initialTheme) {
172
+ if (isJSTheme(initialTheme)) {
173
+ // JS Theme
174
+ this.activeTheme = initialTheme;
175
+ this.currentTheme = initialTheme.name || 'custom-theme';
176
+ try {
177
+ const css = generateCSSVariables(initialTheme);
178
+ injectCSS(css, JS_THEME_STYLE_ID);
179
+ applyThemeAttributes(this.currentTheme, this.config.dataAttribute);
180
+ } catch (e) {
181
+ console.warn('Failed to apply initial JS theme:', e);
182
+ }
183
+ } else {
184
+ // CSS Theme string
185
+ this.currentTheme = initialTheme as string;
186
+ applyThemeAttributes(this.currentTheme, this.config.dataAttribute);
187
+
188
+ // Trigger load async
189
+ this.preloadTheme(this.currentTheme).catch(err => {
190
+ console.warn('Failed to preload initial theme:', err);
191
+ });
192
+ }
193
+ }
142
194
 
143
195
  // Preload themes if configured
144
196
  if (this.config.preload && this.config.preload.length > 0) {
@@ -160,7 +212,14 @@ export class ThemeManager {
160
212
  * @returns Current theme name
161
213
  */
162
214
  public getTheme(): string {
163
- return this.currentTheme || this.config.defaultTheme;
215
+ return this.currentTheme || (typeof this.config.defaultTheme === 'string' ? this.config.defaultTheme : 'unknown');
216
+ }
217
+
218
+ /**
219
+ * Get the current active theme object (for JS themes)
220
+ */
221
+ public getActiveTheme(): Theme | null {
222
+ return this.activeTheme;
164
223
  }
165
224
 
166
225
  /**
@@ -279,69 +338,136 @@ export class ThemeManager {
279
338
  /**
280
339
  * Set the current theme
281
340
  *
282
- * @param themeName - Name of the theme to set
341
+ * @param themeOrName - Name of the theme or Theme object to set
283
342
  * @param options - Load options
284
343
  * @returns Promise that resolves when theme is applied
285
344
  */
286
345
  public async setTheme(
287
- themeName: string,
346
+ themeOrName: string | Theme,
288
347
  options: ThemeLoadOptions = {}
289
348
  ): Promise<void> {
290
349
  if (isServer()) {
291
350
  return Promise.resolve();
292
351
  }
293
352
 
294
- // Validate theme name format to prevent path injection
295
- if (!isValidThemeName(themeName)) {
296
- const error = new Error(`Invalid theme name: "${themeName}". Theme names must be lowercase alphanumeric with hyphens.`);
297
- this.emitError(error, themeName);
298
- throw error;
299
- }
300
-
301
- // Validate theme exists
302
- if (!this.config.themes[themeName]) {
303
- const error = new Error(`Theme "${themeName}" not found`);
304
- this.emitError(error, themeName);
305
- throw error;
353
+ const isJS = isJSTheme(themeOrName);
354
+ let themeName: string;
355
+ let themeObject: Theme | null = null;
356
+
357
+ if (isJS) {
358
+ themeObject = themeOrName as Theme;
359
+ themeName = themeObject.name || 'custom-theme';
360
+ } else {
361
+ themeName = themeOrName as string;
362
+ // Validate theme name format
363
+ if (!isValidThemeName(themeName)) {
364
+ const error = new Error(`Invalid theme name: "${themeName}". Theme names must be lowercase alphanumeric with hyphens.`);
365
+ this.emitError(error, themeName);
366
+ throw error;
367
+ }
368
+ // Validate theme exists in config
369
+ if (!this.config.themes[themeName]) {
370
+ const error = new Error(`Theme "${themeName}" not found`);
371
+ this.emitError(error, themeName);
372
+ throw error;
373
+ }
306
374
  }
307
375
 
308
- // Check if already current theme
376
+ const previousTheme = this.currentTheme;
377
+ const isCurrentlyJS = this.activeTheme !== null;
378
+
379
+ // Check if already current theme (and not forced)
380
+ // Only return early if:
381
+ // 1. Names match
382
+ // 2. Not forced
383
+ // 3. Both are the same type (both CSS or both JS)
384
+ // - If switching from JS to CSS or CSS to JS with same name, we need to proceed
309
385
  if (themeName === this.currentTheme && !options.force) {
310
- return Promise.resolve();
386
+ // If both are CSS themes, return early
387
+ if (!isJS && !isCurrentlyJS) {
388
+ return Promise.resolve();
389
+ }
390
+ // If both are JS themes with the same name, return early
391
+ // (Note: We can't easily compare JS theme objects, but if name matches and both are JS, assume same)
392
+ if (isJS && isCurrentlyJS) {
393
+ return Promise.resolve();
394
+ }
395
+ // If switching between JS and CSS with same name, continue to switch implementations
311
396
  }
312
397
 
313
- const previousTheme = this.currentTheme;
314
-
315
398
  try {
316
- // Load theme CSS if not already loaded
317
- if (!this.isThemeLoaded(themeName) || options.force) {
318
- await this.preloadTheme(themeName);
319
- }
399
+ if (isJS && themeObject) {
400
+ // Handle JS Theme
401
+
402
+ // 1. Generate CSS Variables
403
+ const css = generateCSSVariables(themeObject);
404
+
405
+ // 2. Inject CSS
406
+ injectCSS(css, JS_THEME_STYLE_ID);
407
+
408
+ // 3. Remove previous theme attribute?
409
+ // We might want to keep data-theme attribute if it helps selectors,
410
+ // but if the theme name matches, good.
411
+ applyThemeAttributes(themeName, this.config.dataAttribute);
412
+
413
+ const wasJS = !!this.activeTheme;
414
+ // 4. Set active theme object
415
+ this.activeTheme = themeObject;
416
+
417
+ // 5. If we had a previous CSS theme loaded (and it wasn't a JS theme)
418
+ if (previousTheme && !wasJS && options.removePrevious) {
419
+ // If previously we had a CSS theme, we should remove it.
420
+ // We can check if previousTheme was in availableThemes.
421
+ if (this.config.themes[previousTheme]) {
422
+ removeThemeCSS(previousTheme);
423
+ this.loadedThemes.delete(previousTheme);
424
+ }
425
+ }
320
426
 
321
- // Remove previous theme CSS if requested
322
- if (options.removePrevious && previousTheme && previousTheme !== themeName) {
323
- removeThemeCSS(previousTheme);
324
- this.loadedThemes.delete(previousTheme);
325
- }
427
+ } else {
428
+ // Handle CSS Theme
429
+
430
+ // 1. Clear any active JS theme
431
+ if (this.activeTheme) {
432
+ removeInjectedCSS(JS_THEME_STYLE_ID);
433
+ this.activeTheme = null;
434
+ }
435
+
436
+ // 2. Load CSS if needed
437
+ if (!this.isThemeLoaded(themeName) || options.force) {
438
+ await this.preloadTheme(themeName);
439
+ }
440
+
441
+ // 3. Remove previous theme CSS
442
+ if (options.removePrevious && previousTheme && previousTheme !== themeName) {
443
+ removeThemeCSS(previousTheme);
444
+ this.loadedThemes.delete(previousTheme);
445
+ }
326
446
 
327
- // Apply theme attributes
328
- applyThemeAttributes(themeName, this.config.dataAttribute);
447
+ // 4. Apply attributes
448
+ applyThemeAttributes(themeName, this.config.dataAttribute);
449
+ }
329
450
 
330
- // Update current theme
451
+ // Update current theme name
331
452
  this.currentTheme = themeName;
332
453
 
333
- // Persist to storage
454
+ // Persist (only if string name and in config?)
455
+ // If it's a JS theme, we can't persist the object, but if it has a name, we might persist that
456
+ // and expect the app to restore it (e.g. by re-creating it).
334
457
  if (this.config.enablePersistence && this.storageAdapter.isAvailable()) {
458
+ // Only persist if it's a known theme string or we accept persisting names of JS custom themes
459
+ // For now, let's persist whatever string we have.
335
460
  this.storageAdapter.setItem(this.config.storageKey, themeName);
336
461
  }
337
462
 
338
- // Emit theme change event
339
- this.emitThemeChange(previousTheme, themeName);
463
+ // Emit change event
464
+ this.emitThemeChange(previousTheme, themeName, this.activeTheme);
340
465
 
341
- // Call config callback
466
+ // Callback
342
467
  if (this.config.onThemeChange) {
343
- this.config.onThemeChange(themeName);
468
+ this.config.onThemeChange(isJS ? (themeObject || themeName) : themeName);
344
469
  }
470
+
345
471
  } catch (error) {
346
472
  const err = error instanceof Error ? error : new Error(String(error));
347
473
  this.emitError(err, themeName);
@@ -350,10 +476,24 @@ export class ThemeManager {
350
476
  this.config.onError(err, themeName);
351
477
  }
352
478
 
353
- // Fallback to default theme if requested
354
- if (options.fallbackOnError && themeName !== this.config.defaultTheme) {
355
- console.warn(`Failed to load theme "${themeName}", falling back to default theme "${this.config.defaultTheme}"`);
356
- return this.setTheme(this.config.defaultTheme, { ...options, fallbackOnError: false });
479
+ // Fallback
480
+ if (options.fallbackOnError && this.config.defaultTheme) {
481
+ // Extract theme names consistently to avoid comparing string to Theme object
482
+ const targetName = themeName; // themeName is already a string at this point
483
+ const defName = isJSTheme(this.config.defaultTheme)
484
+ ? this.config.defaultTheme.name
485
+ : this.config.defaultTheme;
486
+
487
+ // Only fallback if target theme is different from default theme
488
+ if (targetName !== defName) {
489
+ const def = this.config.defaultTheme;
490
+ if (def && typeof def !== 'string') {
491
+ // recursively call set theme with default object
492
+ return this.setTheme(def, { ...options, fallbackOnError: false });
493
+ } else if (typeof def === 'string') {
494
+ return this.setTheme(def, { ...options, fallbackOnError: false });
495
+ }
496
+ }
357
497
  }
358
498
 
359
499
  throw err;
@@ -398,8 +538,10 @@ export class ThemeManager {
398
538
  }
399
539
 
400
540
  removeAllThemeCSS();
541
+ removeInjectedCSS(JS_THEME_STYLE_ID);
401
542
  this.loadedThemes.clear();
402
543
  this.loadingThemes.clear();
544
+ this.activeTheme = null;
403
545
  }
404
546
 
405
547
  /**
@@ -443,10 +585,11 @@ export class ThemeManager {
443
585
  /**
444
586
  * Emit theme change event
445
587
  */
446
- private emitThemeChange(previousTheme: string | null, currentTheme: string): void {
588
+ private emitThemeChange(previousTheme: string | null, currentTheme: string, themeObject?: Theme | null): void {
447
589
  const event: ThemeChangeEvent = {
448
590
  previousTheme,
449
591
  currentTheme,
592
+ themeObject,
450
593
  timestamp: Date.now(),
451
594
  source: 'user',
452
595
  };
@@ -495,6 +638,7 @@ export class ThemeManager {
495
638
  this.eventListeners.themeLoad = [];
496
639
  this.eventListeners.themeError = [];
497
640
  this.initialized = false;
641
+ this.activeTheme = null;
498
642
  }
499
643
  }
500
644