@shellui/core 0.2.0 → 0.3.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +143 -0
  78. package/src/features/settings/utils/buildSettingsForPropagation.ts +55 -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,143 @@
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
+ });
@@ -0,0 +1,55 @@
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
+ result = {
50
+ ...result,
51
+ accessToken: options?.includeAuthAccessToken ? (options.accessToken ?? null) : null,
52
+ };
53
+
54
+ return result;
55
+ };
@@ -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
+ };
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Settings } from '@shellui/sdk';
3
+ import { mergePreferencesIntoSettings } from './mergePreferencesIntoSettings';
4
+
5
+ const baseSettings: Settings = {
6
+ developerFeatures: { enabled: false },
7
+ errorReporting: { enabled: true },
8
+ logging: { namespaces: { shellsdk: false, shellcore: false } },
9
+ appearance: {
10
+ name: 'default',
11
+ displayName: 'Default',
12
+ mode: 'light',
13
+ colorScheme: 'system',
14
+ colors: {
15
+ light: {
16
+ background: '#fff',
17
+ foreground: '#000',
18
+ card: '#fff',
19
+ cardForeground: '#000',
20
+ popover: '#fff',
21
+ popoverForeground: '#000',
22
+ primary: '#000',
23
+ primaryForeground: '#fff',
24
+ secondary: '#eee',
25
+ secondaryForeground: '#000',
26
+ muted: '#eee',
27
+ mutedForeground: '#333',
28
+ accent: '#eee',
29
+ accentForeground: '#000',
30
+ destructive: '#f00',
31
+ destructiveForeground: '#fff',
32
+ border: '#ccc',
33
+ input: '#ccc',
34
+ ring: '#000',
35
+ radius: '0.5rem',
36
+ sidebarBackground: '#fff',
37
+ sidebarForeground: '#000',
38
+ sidebarPrimary: '#000',
39
+ sidebarPrimaryForeground: '#fff',
40
+ sidebarAccent: '#eee',
41
+ sidebarAccentForeground: '#000',
42
+ sidebarBorder: '#ccc',
43
+ sidebarRing: '#000',
44
+ },
45
+ dark: {
46
+ background: '#000',
47
+ foreground: '#fff',
48
+ card: '#000',
49
+ cardForeground: '#fff',
50
+ popover: '#000',
51
+ popoverForeground: '#fff',
52
+ primary: '#fff',
53
+ primaryForeground: '#000',
54
+ secondary: '#111',
55
+ secondaryForeground: '#fff',
56
+ muted: '#111',
57
+ mutedForeground: '#ddd',
58
+ accent: '#111',
59
+ accentForeground: '#fff',
60
+ destructive: '#900',
61
+ destructiveForeground: '#fff',
62
+ border: '#333',
63
+ input: '#333',
64
+ ring: '#fff',
65
+ radius: '0.5rem',
66
+ sidebarBackground: '#000',
67
+ sidebarForeground: '#fff',
68
+ sidebarPrimary: '#fff',
69
+ sidebarPrimaryForeground: '#000',
70
+ sidebarAccent: '#111',
71
+ sidebarAccentForeground: '#fff',
72
+ sidebarBorder: '#333',
73
+ sidebarRing: '#fff',
74
+ },
75
+ },
76
+ },
77
+ language: { code: 'en' },
78
+ region: { timezone: 'UTC' },
79
+ cookieConsent: { acceptedHosts: [], consentedCookieHosts: [] },
80
+ serviceWorker: { enabled: true },
81
+ user: null,
82
+ };
83
+
84
+ describe('mergePreferencesIntoSettings', () => {
85
+ it('applies valid preference values', () => {
86
+ const result = mergePreferencesIntoSettings(baseSettings, {
87
+ themeName: 'blue',
88
+ language: 'fr',
89
+ region: 'Europe/Paris',
90
+ colorScheme: 'dark',
91
+ });
92
+
93
+ expect(result.appearance.name).toBe('blue');
94
+ expect(result.appearance.colorScheme).toBe('dark');
95
+ expect(result.language.code).toBe('fr');
96
+ expect(result.region.timezone).toBe('Europe/Paris');
97
+ });
98
+
99
+ it('keeps current language and color scheme when incoming values are invalid', () => {
100
+ const result = mergePreferencesIntoSettings(baseSettings, {
101
+ language: 'es',
102
+ colorScheme: undefined,
103
+ });
104
+
105
+ expect(result.language.code).toBe('en');
106
+ expect(result.appearance.colorScheme).toBe('system');
107
+ });
108
+ });