@shellui/core 0.2.0 → 0.3.0-beta.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 (111) hide show
  1. package/package.json +9 -4
  2. package/src/app.tsx +12 -9
  3. package/src/components/ui/badge.tsx +35 -0
  4. package/src/components/ui/dropdown-menu.tsx +94 -0
  5. package/src/components/ui/sidebar.tsx +1 -1
  6. package/src/constants/urls.ts +8 -0
  7. package/src/features/admin/AdminView.tsx +154 -0
  8. package/src/features/admin/components/AdminForbiddenAccess.tsx +10 -0
  9. package/src/features/auth/AuthProvider.tsx +464 -0
  10. package/src/features/auth/backends/index.ts +41 -0
  11. package/src/features/auth/backends/shellui.ts +278 -0
  12. package/src/features/auth/backends/supabase.ts +300 -0
  13. package/src/features/auth/backends/types.ts +30 -0
  14. package/src/features/auth/components/LoginButton.tsx +360 -0
  15. package/src/features/auth/components/LoginButtonIcons.tsx +48 -0
  16. package/src/features/auth/components/LoginView.tsx +721 -0
  17. package/src/features/auth/components/OAuthCallbackView.tsx +119 -0
  18. package/src/features/auth/hooks/useAuth.tsx +37 -0
  19. package/src/features/auth/types.ts +51 -0
  20. package/src/features/auth/utils/buildSessionFromParams.spec.ts +61 -0
  21. package/src/features/auth/utils/buildSessionFromParams.ts +79 -0
  22. package/src/features/auth/utils/clearStoredAuthSession.spec.ts +23 -0
  23. package/src/features/auth/utils/clearStoredAuthSession.ts +10 -0
  24. package/src/features/auth/utils/clientLoginContext.spec.ts +83 -0
  25. package/src/features/auth/utils/clientLoginContext.ts +89 -0
  26. package/src/features/auth/utils/decodeJwtPayload.spec.ts +17 -0
  27. package/src/features/auth/utils/decodeJwtPayload.ts +24 -0
  28. package/src/features/auth/utils/formatProviderLabel.spec.ts +19 -0
  29. package/src/features/auth/utils/formatProviderLabel.ts +11 -0
  30. package/src/features/auth/utils/getOAuthProviderCandidates.spec.ts +16 -0
  31. package/src/features/auth/utils/getOAuthProviderCandidates.ts +15 -0
  32. package/src/features/auth/utils/getPreferredBackendProvider.spec.ts +15 -0
  33. package/src/features/auth/utils/getPreferredBackendProvider.ts +10 -0
  34. package/src/features/auth/utils/getProviderVisual.spec.ts +30 -0
  35. package/src/features/auth/utils/getProviderVisual.ts +83 -0
  36. package/src/features/auth/utils/getUserFromSdkSettings.spec.ts +32 -0
  37. package/src/features/auth/utils/getUserFromSdkSettings.ts +13 -0
  38. package/src/features/auth/utils/index.ts +21 -0
  39. package/src/features/auth/utils/isLoginMethod.spec.ts +18 -0
  40. package/src/features/auth/utils/isLoginMethod.ts +5 -0
  41. package/src/features/auth/utils/isSessionExpired.spec.ts +23 -0
  42. package/src/features/auth/utils/isSessionExpired.ts +5 -0
  43. package/src/features/auth/utils/normalizeAuthSettings.spec.ts +60 -0
  44. package/src/features/auth/utils/normalizeAuthSettings.ts +71 -0
  45. package/src/features/auth/utils/normalizeNextPath.spec.ts +21 -0
  46. package/src/features/auth/utils/normalizeNextPath.ts +12 -0
  47. package/src/features/auth/utils/normalizeRedirectPath.spec.ts +12 -0
  48. package/src/features/auth/utils/normalizeRedirectPath.ts +3 -0
  49. package/src/features/auth/utils/persistAuthSession.spec.ts +35 -0
  50. package/src/features/auth/utils/persistAuthSession.ts +12 -0
  51. package/src/features/auth/utils/readStoredAuthSession.spec.ts +34 -0
  52. package/src/features/auth/utils/readStoredAuthSession.ts +14 -0
  53. package/src/features/auth/utils/toAuthSessionFromSettingsUser.spec.ts +76 -0
  54. package/src/features/auth/utils/toAuthSessionFromSettingsUser.ts +36 -0
  55. package/src/features/config/types.ts +55 -0
  56. package/src/features/layouts/AppLayout.tsx +8 -6
  57. package/src/features/layouts/appbar/AppBarLayout.tsx +42 -23
  58. package/src/features/layouts/fullscreen/FullscreenLayout.tsx +3 -2
  59. package/src/features/layouts/sidebar/SidebarInner.tsx +7 -5
  60. package/src/features/layouts/sidebar/SidebarLayout.tsx +16 -3
  61. package/src/features/layouts/utils.ts +54 -0
  62. package/src/features/layouts/windows/WindowsLayout.tsx +22 -4
  63. package/src/features/legal/LegalDocumentContent.tsx +102 -0
  64. package/src/features/legal/LegalDocumentView.tsx +42 -0
  65. package/src/features/legal/LegalDocumentsIndexView.tsx +51 -0
  66. package/src/features/legal/LegalDocumentsLinks.tsx +29 -0
  67. package/src/features/legal/legalDocuments.ts +62 -0
  68. package/src/features/settings/SettingsIcons.tsx +20 -0
  69. package/src/features/settings/SettingsProvider.tsx +347 -245
  70. package/src/features/settings/SettingsRoutes.tsx +8 -0
  71. package/src/features/settings/SettingsView.tsx +43 -8
  72. package/src/features/settings/components/Develop.tsx +2 -2
  73. package/src/features/settings/components/LegalDocumentsPanel.tsx +46 -0
  74. package/src/features/settings/components/UserIcon.tsx +20 -0
  75. package/src/features/settings/components/UserSettingsPanel.tsx +438 -0
  76. package/src/features/settings/components/createUserSettingsRoute.tsx +43 -0
  77. package/src/features/settings/utils/buildSettingsForPropagation.spec.ts +167 -0
  78. package/src/features/settings/utils/buildSettingsForPropagation.ts +61 -0
  79. package/src/features/settings/utils/flattenNavigationItems.spec.ts +17 -0
  80. package/src/features/settings/utils/flattenNavigationItems.ts +12 -0
  81. package/src/features/settings/utils/getAvailableThemesForSettings.spec.ts +15 -0
  82. package/src/features/settings/utils/getAvailableThemesForSettings.ts +16 -0
  83. package/src/features/settings/utils/getBrowserTimezone.spec.ts +11 -0
  84. package/src/features/settings/utils/getBrowserTimezone.ts +7 -0
  85. package/src/features/settings/utils/getPreferenceSnapshot.spec.ts +35 -0
  86. package/src/features/settings/utils/getPreferenceSnapshot.ts +10 -0
  87. package/src/features/settings/utils/getResolvedAppearanceForSettings.spec.ts +97 -0
  88. package/src/features/settings/utils/getResolvedAppearanceForSettings.ts +48 -0
  89. package/src/features/settings/utils/index.ts +12 -0
  90. package/src/features/settings/utils/isSameUser.spec.ts +35 -0
  91. package/src/features/settings/utils/isSameUser.ts +17 -0
  92. package/src/features/settings/utils/mergePreferencesIntoSettings.spec.ts +108 -0
  93. package/src/features/settings/utils/mergePreferencesIntoSettings.ts +47 -0
  94. package/src/features/settings/utils/resolveColorMode.spec.ts +29 -0
  95. package/src/features/settings/utils/resolveColorMode.ts +6 -0
  96. package/src/features/settings/utils/resolveLabel.spec.ts +17 -0
  97. package/src/features/settings/utils/resolveLabel.ts +7 -0
  98. package/src/features/settings/utils/toAbsoluteFontUrls.spec.ts +26 -0
  99. package/src/features/settings/utils/toAbsoluteFontUrls.ts +15 -0
  100. package/src/features/settings/utils/toSettingsUser.spec.ts +49 -0
  101. package/src/features/settings/utils/toSettingsUser.ts +15 -0
  102. package/src/i18n/translations/en/common.json +14 -0
  103. package/src/i18n/translations/en/settings.json +45 -0
  104. package/src/i18n/translations/fr/common.json +14 -0
  105. package/src/i18n/translations/fr/settings.json +45 -0
  106. package/src/index.css +37 -0
  107. package/src/index.ts +6 -0
  108. package/src/routes/components/NavigationItemRoute.tsx +32 -1
  109. package/src/routes/components/NotFoundView.tsx +13 -3
  110. package/src/routes/hooks/useNavigationItems.ts +19 -4
  111. package/src/routes/routes.tsx +87 -0
@@ -0,0 +1,167 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { Settings } from '@shellui/sdk';
3
+ import type { ShellUIConfig } from '../../config/types';
4
+ import { buildSettingsForPropagation } from './buildSettingsForPropagation';
5
+
6
+ const baseSettings: Settings = {
7
+ developerFeatures: { enabled: false },
8
+ errorReporting: { enabled: true },
9
+ logging: { namespaces: { shellsdk: false, shellcore: false } },
10
+ appearance: {
11
+ name: 'default',
12
+ displayName: 'Default',
13
+ mode: 'light',
14
+ colorScheme: 'light',
15
+ colors: {
16
+ light: {
17
+ background: '#fff',
18
+ foreground: '#000',
19
+ card: '#fff',
20
+ cardForeground: '#000',
21
+ popover: '#fff',
22
+ popoverForeground: '#000',
23
+ primary: '#000',
24
+ primaryForeground: '#fff',
25
+ secondary: '#eee',
26
+ secondaryForeground: '#000',
27
+ muted: '#eee',
28
+ mutedForeground: '#333',
29
+ accent: '#eee',
30
+ accentForeground: '#000',
31
+ destructive: '#f00',
32
+ destructiveForeground: '#fff',
33
+ border: '#ccc',
34
+ input: '#ccc',
35
+ ring: '#000',
36
+ radius: '0.5rem',
37
+ sidebarBackground: '#fff',
38
+ sidebarForeground: '#000',
39
+ sidebarPrimary: '#000',
40
+ sidebarPrimaryForeground: '#fff',
41
+ sidebarAccent: '#eee',
42
+ sidebarAccentForeground: '#000',
43
+ sidebarBorder: '#ccc',
44
+ sidebarRing: '#000',
45
+ },
46
+ dark: {
47
+ background: '#000',
48
+ foreground: '#fff',
49
+ card: '#000',
50
+ cardForeground: '#fff',
51
+ popover: '#000',
52
+ popoverForeground: '#fff',
53
+ primary: '#fff',
54
+ primaryForeground: '#000',
55
+ secondary: '#111',
56
+ secondaryForeground: '#fff',
57
+ muted: '#111',
58
+ mutedForeground: '#ddd',
59
+ accent: '#111',
60
+ accentForeground: '#fff',
61
+ destructive: '#900',
62
+ destructiveForeground: '#fff',
63
+ border: '#333',
64
+ input: '#333',
65
+ ring: '#fff',
66
+ radius: '0.5rem',
67
+ sidebarBackground: '#000',
68
+ sidebarForeground: '#fff',
69
+ sidebarPrimary: '#fff',
70
+ sidebarPrimaryForeground: '#000',
71
+ sidebarAccent: '#111',
72
+ sidebarAccentForeground: '#fff',
73
+ sidebarBorder: '#333',
74
+ sidebarRing: '#fff',
75
+ },
76
+ },
77
+ },
78
+ language: { code: 'en' },
79
+ region: { timezone: 'UTC' },
80
+ cookieConsent: { acceptedHosts: [], consentedCookieHosts: [] },
81
+ serviceWorker: { enabled: true },
82
+ user: null,
83
+ };
84
+
85
+ describe('buildSettingsForPropagation', () => {
86
+ it('adds localized navigation items and available themes', () => {
87
+ vi.stubGlobal('window', {
88
+ location: { origin: 'https://shellui.dev' },
89
+ matchMedia: () => ({ matches: false }),
90
+ });
91
+
92
+ const config = {
93
+ navigation: [
94
+ {
95
+ title: 'Main',
96
+ items: [
97
+ {
98
+ label: { en: 'Docs', fr: 'Docs FR' },
99
+ path: '/docs',
100
+ url: '/docs',
101
+ },
102
+ ],
103
+ },
104
+ ],
105
+ } as ShellUIConfig;
106
+
107
+ const result = buildSettingsForPropagation(baseSettings, config, 'fr');
108
+
109
+ expect(result.navigation?.items).toEqual([
110
+ {
111
+ path: '/docs',
112
+ url: '/docs',
113
+ label: 'Docs FR',
114
+ },
115
+ ]);
116
+ expect(result.appearance?.availableThemes?.length).toBeGreaterThan(0);
117
+ });
118
+
119
+ it('injects access token into settings.user only when explicitly allowed', () => {
120
+ const settingsWithUser: Settings = {
121
+ ...baseSettings,
122
+ user: {
123
+ id: 'u1',
124
+ email: 'dev@shellui.dev',
125
+ name: 'Dev User',
126
+ profilePicture: null,
127
+ authProvider: 'github',
128
+ },
129
+ };
130
+
131
+ const safeResult = buildSettingsForPropagation(settingsWithUser, undefined, 'en', {
132
+ includeAuthAccessToken: true,
133
+ accessToken: 'jwt.safe.token',
134
+ });
135
+ expect(safeResult.accessToken).toBe('jwt.safe.token');
136
+
137
+ const unsafeResult = buildSettingsForPropagation(settingsWithUser, undefined, 'en', {
138
+ includeAuthAccessToken: false,
139
+ accessToken: 'jwt.should.not.be.exposed',
140
+ });
141
+ expect(unsafeResult.accessToken).toBeNull();
142
+ });
143
+
144
+ it('injects authBackendBaseUrl when backend type is shellui', () => {
145
+ const config = {
146
+ backend: {
147
+ type: 'shellui' as const,
148
+ url: 'https://id.example.com/',
149
+ },
150
+ } as ShellUIConfig;
151
+
152
+ const result = buildSettingsForPropagation(baseSettings, config, 'en');
153
+ expect(result.authBackendBaseUrl).toBe('https://id.example.com');
154
+ });
155
+
156
+ it('sets authBackendBaseUrl to null when backend is not shellui', () => {
157
+ const config = {
158
+ backend: {
159
+ type: 'supabase' as const,
160
+ url: 'https://xyz.supabase.co',
161
+ },
162
+ } as ShellUIConfig;
163
+
164
+ const result = buildSettingsForPropagation(baseSettings, config, 'en');
165
+ expect(result.authBackendBaseUrl).toBeNull();
166
+ });
167
+ });
@@ -0,0 +1,61 @@
1
+ import type { Settings, SettingsNavigationItem } from '@shellui/sdk';
2
+ import type { ShellUIConfig } from '../../config/types';
3
+ import { flattenNavigationItems } from './flattenNavigationItems';
4
+ import { getAvailableThemesForSettings } from './getAvailableThemesForSettings';
5
+ import { getResolvedAppearanceForSettings } from './getResolvedAppearanceForSettings';
6
+ import { resolveLabel } from './resolveLabel';
7
+
8
+ /**
9
+ * Build settings for propagation to iframes: inject navigation, full theme object,
10
+ * and list of available themes so apps can render theme pickers.
11
+ */
12
+ export const buildSettingsForPropagation = (
13
+ settings: Settings,
14
+ config: ShellUIConfig | undefined,
15
+ lang: string,
16
+ options?: {
17
+ includeAuthAccessToken?: boolean;
18
+ accessToken?: string | null;
19
+ },
20
+ ): Settings => {
21
+ const appearance = getResolvedAppearanceForSettings(settings, config);
22
+ let result: Settings = {
23
+ ...settings,
24
+ appearance: appearance ?? settings.appearance,
25
+ };
26
+
27
+ // Inject available themes when we have a resolved appearance (themes are already registered above)
28
+ if (result.appearance && typeof window !== 'undefined') {
29
+ result = {
30
+ ...result,
31
+ appearance: {
32
+ ...result.appearance,
33
+ availableThemes: getAvailableThemesForSettings(),
34
+ },
35
+ };
36
+ }
37
+
38
+ if (config?.navigation?.length) {
39
+ const items: SettingsNavigationItem[] = flattenNavigationItems(config.navigation).map(
40
+ (item) => ({
41
+ path: item.path,
42
+ url: item.url,
43
+ label: resolveLabel(item.label, lang),
44
+ }),
45
+ );
46
+ result = { ...result, navigation: { items } };
47
+ }
48
+
49
+ const authBackendBaseUrl =
50
+ config?.backend?.type === 'shellui' && config.backend.url?.trim()
51
+ ? config.backend.url.trim().replace(/\/+$/, '')
52
+ : null;
53
+
54
+ result = {
55
+ ...result,
56
+ accessToken: options?.includeAuthAccessToken ? (options.accessToken ?? null) : null,
57
+ authBackendBaseUrl,
58
+ };
59
+
60
+ return result;
61
+ };
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { NavigationGroup, NavigationItem } from '../../config/types';
3
+ import { flattenNavigationItems } from './flattenNavigationItems';
4
+
5
+ describe('flattenNavigationItems', () => {
6
+ it('returns empty list when navigation is empty', () => {
7
+ expect(flattenNavigationItems([])).toEqual([]);
8
+ });
9
+
10
+ it('flattens grouped and standalone navigation items', () => {
11
+ const docsItem: NavigationItem = { label: 'Docs', path: '/docs', url: '/docs' };
12
+ const blogItem: NavigationItem = { label: 'Blog', path: '/blog', url: '/blog' };
13
+ const group: NavigationGroup = { title: 'Group', items: [docsItem] };
14
+
15
+ expect(flattenNavigationItems([group, blogItem])).toEqual([docsItem, blogItem]);
16
+ });
17
+ });
@@ -0,0 +1,12 @@
1
+ import type { NavigationGroup, NavigationItem } from '../../config/types';
2
+
3
+ export const flattenNavigationItems = (
4
+ navigation: (NavigationItem | NavigationGroup)[],
5
+ ): NavigationItem[] => {
6
+ if (navigation.length === 0) return [];
7
+
8
+ return navigation.flatMap((item) => {
9
+ if ('title' in item && 'items' in item) return item.items;
10
+ return [item];
11
+ });
12
+ };
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getAvailableThemesForSettings } from './getAvailableThemesForSettings';
3
+
4
+ describe('getAvailableThemesForSettings', () => {
5
+ it('returns registered themes in the lightweight settings shape', () => {
6
+ const themes = getAvailableThemesForSettings();
7
+
8
+ expect(themes.length).toBeGreaterThan(0);
9
+ expect(themes[0]).toMatchObject({
10
+ name: expect.any(String),
11
+ displayName: expect.any(String),
12
+ colors: expect.any(Object),
13
+ });
14
+ });
15
+ });
@@ -0,0 +1,16 @@
1
+ import type { SettingsAvailableTheme } from '@shellui/sdk';
2
+ import { getAllThemes } from '../../theme/themes';
3
+
4
+ /**
5
+ * Map registered themes to the slim shape sent to sub-apps
6
+ * (name, displayName, colors, optional typography for preview).
7
+ */
8
+ export const getAvailableThemesForSettings = (): SettingsAvailableTheme[] =>
9
+ getAllThemes().map((theme) => ({
10
+ name: theme.name,
11
+ displayName: theme.displayName,
12
+ colors: theme.colors,
13
+ ...(theme.fontFamily !== undefined && { fontFamily: theme.fontFamily }),
14
+ ...(theme.letterSpacing !== undefined && { letterSpacing: theme.letterSpacing }),
15
+ ...(theme.textShadow !== undefined && { textShadow: theme.textShadow }),
16
+ }));
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getBrowserTimezone } from './getBrowserTimezone';
3
+
4
+ describe('getBrowserTimezone', () => {
5
+ it('returns a non-empty timezone string', () => {
6
+ const timezone = getBrowserTimezone();
7
+
8
+ expect(typeof timezone).toBe('string');
9
+ expect(timezone.length).toBeGreaterThan(0);
10
+ });
11
+ });
@@ -0,0 +1,7 @@
1
+ // Get browser's timezone as default
2
+ export const getBrowserTimezone = (): string => {
3
+ if (typeof window !== 'undefined' && Intl) {
4
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
5
+ }
6
+ return 'UTC';
7
+ };
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Settings } from '@shellui/sdk';
3
+ import { getPreferenceSnapshot } from './getPreferenceSnapshot';
4
+
5
+ describe('getPreferenceSnapshot', () => {
6
+ it('returns explicit preferences from settings', () => {
7
+ const settings = {
8
+ appearance: { name: 'blue', colorScheme: 'dark' },
9
+ language: { code: 'fr' },
10
+ region: { timezone: 'Europe/Paris' },
11
+ } as Settings;
12
+
13
+ expect(getPreferenceSnapshot(settings)).toEqual({
14
+ themeName: 'blue',
15
+ language: 'fr',
16
+ region: 'Europe/Paris',
17
+ colorScheme: 'dark',
18
+ });
19
+ });
20
+
21
+ it('falls back to default values when settings fields are missing', () => {
22
+ const settings = {
23
+ appearance: { name: '', colorScheme: 'system' },
24
+ language: { code: 'en' },
25
+ region: { timezone: '' },
26
+ } as Settings;
27
+
28
+ const snapshot = getPreferenceSnapshot(settings);
29
+ expect(snapshot.themeName).toBe('default');
30
+ expect(snapshot.language).toBe('en');
31
+ expect(snapshot.colorScheme).toBe('system');
32
+ expect(typeof snapshot.region).toBe('string');
33
+ expect(snapshot.region?.length).toBeGreaterThan(0);
34
+ });
35
+ });
@@ -0,0 +1,10 @@
1
+ import type { Settings } from '@shellui/sdk';
2
+ import { getBrowserTimezone } from './getBrowserTimezone';
3
+ import type { AppPreferences } from './mergePreferencesIntoSettings';
4
+
5
+ export const getPreferenceSnapshot = (settings: Settings): AppPreferences => ({
6
+ themeName: settings.appearance?.name || 'default',
7
+ language: settings.language?.code || 'en',
8
+ region: settings.region?.timezone || getBrowserTimezone(),
9
+ colorScheme: settings.appearance?.colorScheme || 'system',
10
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { Settings } from '@shellui/sdk';
3
+ import type { ShellUIConfig, ThemeDefinition } from '../../config/types';
4
+ import { getResolvedAppearanceForSettings } from './getResolvedAppearanceForSettings';
5
+
6
+ describe('getResolvedAppearanceForSettings', () => {
7
+ it('resolves and returns full appearance with registered theme data', () => {
8
+ vi.stubGlobal('window', {
9
+ location: { origin: 'https://shellui.dev' },
10
+ matchMedia: () => ({ matches: true }),
11
+ });
12
+
13
+ const customTheme: ThemeDefinition = {
14
+ name: 'custom-theme',
15
+ displayName: 'Custom Theme',
16
+ colors: {
17
+ light: {
18
+ background: '#fff',
19
+ foreground: '#000',
20
+ card: '#fff',
21
+ cardForeground: '#000',
22
+ popover: '#fff',
23
+ popoverForeground: '#000',
24
+ primary: '#000',
25
+ primaryForeground: '#fff',
26
+ secondary: '#eee',
27
+ secondaryForeground: '#000',
28
+ muted: '#eee',
29
+ mutedForeground: '#333',
30
+ accent: '#eee',
31
+ accentForeground: '#000',
32
+ destructive: '#f00',
33
+ destructiveForeground: '#fff',
34
+ border: '#ccc',
35
+ input: '#ccc',
36
+ ring: '#000',
37
+ radius: '0.5rem',
38
+ sidebarBackground: '#fff',
39
+ sidebarForeground: '#000',
40
+ sidebarPrimary: '#000',
41
+ sidebarPrimaryForeground: '#fff',
42
+ sidebarAccent: '#eee',
43
+ sidebarAccentForeground: '#000',
44
+ sidebarBorder: '#ccc',
45
+ sidebarRing: '#000',
46
+ },
47
+ dark: {
48
+ background: '#000',
49
+ foreground: '#fff',
50
+ card: '#000',
51
+ cardForeground: '#fff',
52
+ popover: '#000',
53
+ popoverForeground: '#fff',
54
+ primary: '#fff',
55
+ primaryForeground: '#000',
56
+ secondary: '#111',
57
+ secondaryForeground: '#fff',
58
+ muted: '#111',
59
+ mutedForeground: '#ddd',
60
+ accent: '#111',
61
+ accentForeground: '#fff',
62
+ destructive: '#900',
63
+ destructiveForeground: '#fff',
64
+ border: '#333',
65
+ input: '#333',
66
+ ring: '#fff',
67
+ radius: '0.5rem',
68
+ sidebarBackground: '#000',
69
+ sidebarForeground: '#fff',
70
+ sidebarPrimary: '#fff',
71
+ sidebarPrimaryForeground: '#000',
72
+ sidebarAccent: '#111',
73
+ sidebarAccentForeground: '#fff',
74
+ sidebarBorder: '#333',
75
+ sidebarRing: '#fff',
76
+ },
77
+ },
78
+ fontFiles: ['fonts/custom.woff2'],
79
+ fontFamily: 'Inter',
80
+ };
81
+
82
+ const settings = {
83
+ appearance: { name: 'custom-theme', colorScheme: 'system' },
84
+ } as Settings;
85
+
86
+ const config = {
87
+ themes: [customTheme],
88
+ } as ShellUIConfig;
89
+
90
+ const appearance = getResolvedAppearanceForSettings(settings, config);
91
+
92
+ expect(appearance?.name).toBe('custom-theme');
93
+ expect(appearance?.mode).toBe('dark');
94
+ expect(appearance?.fontFamily).toBe('Inter');
95
+ expect(appearance?.fontFiles).toEqual(['https://shellui.dev/fonts/custom.woff2']);
96
+ });
97
+ });
@@ -0,0 +1,48 @@
1
+ import type { Appearance, Settings } from '@shellui/sdk';
2
+ import { getTheme, registerTheme } from '../../theme/themes';
3
+ import type { ShellUIConfig } from '../../config/types';
4
+ import { resolveColorMode } from './resolveColorMode';
5
+ import { toAbsoluteFontUrls } from './toAbsoluteFontUrls';
6
+
7
+ /**
8
+ * Build the full appearance object for settings propagation so apps receive all theme
9
+ * variable values and can style without knowing the theme name.
10
+ */
11
+ export const getResolvedAppearanceForSettings = (
12
+ settings: Settings,
13
+ config: ShellUIConfig | undefined,
14
+ ): Appearance | undefined => {
15
+ if (typeof window === 'undefined') return undefined;
16
+
17
+ config?.themes?.forEach(registerTheme);
18
+ const themeName = settings.appearance?.name || config?.defaultTheme || 'default';
19
+ const themeDef = getTheme(themeName) || getTheme('default');
20
+ if (!themeDef) return undefined;
21
+
22
+ const colorScheme = settings.appearance?.colorScheme ?? 'system';
23
+ const mode = resolveColorMode(colorScheme);
24
+
25
+ return {
26
+ name: themeDef.name,
27
+ displayName: themeDef.displayName,
28
+ mode,
29
+ colorScheme,
30
+ colors: themeDef.colors,
31
+ ...(themeDef.fontFamily !== undefined && { fontFamily: themeDef.fontFamily }),
32
+ ...(themeDef.bodyFontFamily !== undefined && {
33
+ bodyFontFamily: themeDef.bodyFontFamily,
34
+ }),
35
+ ...(themeDef.headingFontFamily !== undefined && {
36
+ headingFontFamily: themeDef.headingFontFamily,
37
+ }),
38
+ ...(themeDef.letterSpacing !== undefined && {
39
+ letterSpacing: themeDef.letterSpacing,
40
+ }),
41
+ ...(themeDef.textShadow !== undefined && { textShadow: themeDef.textShadow }),
42
+ ...(themeDef.lineHeight !== undefined && { lineHeight: themeDef.lineHeight }),
43
+ ...(themeDef.fontFiles !== undefined &&
44
+ themeDef.fontFiles.length > 0 && {
45
+ fontFiles: toAbsoluteFontUrls(themeDef.fontFiles),
46
+ }),
47
+ };
48
+ };
@@ -0,0 +1,12 @@
1
+ export { buildSettingsForPropagation } from './buildSettingsForPropagation';
2
+ export { flattenNavigationItems } from './flattenNavigationItems';
3
+ export { getAvailableThemesForSettings } from './getAvailableThemesForSettings';
4
+ export { getBrowserTimezone } from './getBrowserTimezone';
5
+ export { getPreferenceSnapshot } from './getPreferenceSnapshot';
6
+ export { getResolvedAppearanceForSettings } from './getResolvedAppearanceForSettings';
7
+ export { isSameUser } from './isSameUser';
8
+ export { mergePreferencesIntoSettings, type AppPreferences } from './mergePreferencesIntoSettings';
9
+ export { resolveColorMode } from './resolveColorMode';
10
+ export { resolveLabel } from './resolveLabel';
11
+ export { toAbsoluteFontUrls } from './toAbsoluteFontUrls';
12
+ export { toSettingsUser } from './toSettingsUser';
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isSameUser } from './isSameUser';
3
+
4
+ describe('isSameUser', () => {
5
+ it('returns true when both users are null', () => {
6
+ expect(isSameUser(null, null)).toBe(true);
7
+ });
8
+
9
+ it('returns false when one user is missing', () => {
10
+ expect(
11
+ isSameUser(
12
+ {
13
+ id: 'u1',
14
+ email: 'test@example.com',
15
+ name: 'Test',
16
+ profilePicture: null,
17
+ authProvider: 'github',
18
+ },
19
+ null,
20
+ ),
21
+ ).toBe(false);
22
+ });
23
+
24
+ it('returns true when all user fields are identical', () => {
25
+ const user = {
26
+ id: 'u1',
27
+ email: 'test@example.com',
28
+ name: 'Test',
29
+ profilePicture: null,
30
+ authProvider: 'github',
31
+ };
32
+
33
+ expect(isSameUser(user, { ...user })).toBe(true);
34
+ });
35
+ });
@@ -0,0 +1,17 @@
1
+ import type { Settings } from '@shellui/sdk';
2
+
3
+ const groupsFingerprint = (g: Settings['user']): string => [...(g?.groups ?? [])].sort().join('\0');
4
+
5
+ export const isSameUser = (a: Settings['user'], b: Settings['user']) => {
6
+ if (!a && !b) return true;
7
+ if (!a || !b) return false;
8
+
9
+ return (
10
+ a.id === b.id &&
11
+ a.email === b.email &&
12
+ a.name === b.name &&
13
+ a.profilePicture === b.profilePicture &&
14
+ a.authProvider === b.authProvider &&
15
+ groupsFingerprint(a) === groupsFingerprint(b)
16
+ );
17
+ };