@shellui/core 0.2.0-alpha.2 → 0.2.0-alpha.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shellui/core",
3
- "version": "0.2.0-alpha.2",
3
+ "version": "0.2.0-alpha.4",
4
4
  "description": "ShellUI Core - Core React application runtime",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
@@ -58,7 +58,7 @@
58
58
  "workbox-strategies": "^7.1.0",
59
59
  "workbox-cacheable-response": "^7.1.0",
60
60
  "workbox-expiration": "^7.1.0",
61
- "@shellui/sdk": "0.2.0-alpha.2"
61
+ "@shellui/sdk": "0.2.0-alpha.4"
62
62
  },
63
63
  "peerDependencies": {
64
64
  "react": "^18.0.0 || ^19.0.0",
@@ -30,9 +30,13 @@ export const ContentView = ({
30
30
  const navigate = useNavigate();
31
31
  const iframeRef = useRef<HTMLIFrameElement>(null);
32
32
  const isInternalNavigation = useRef(false);
33
+ const cancelRevealRef = useRef<(() => void) | null>(null);
34
+ const mountTimeRef = useRef(Date.now());
33
35
  const [initialUrl] = useState(url);
34
36
  const [isLoading, setIsLoading] = useState(true);
35
37
 
38
+ const MIN_LOADING_MS = 80; // Don't reveal before this, reduces blink from theme/layout paint
39
+
36
40
  useEffect(() => {
37
41
  if (!iframeRef.current) {
38
42
  return;
@@ -104,25 +108,63 @@ export const ContentView = ({
104
108
  };
105
109
  }, [pathPrefix, navigate]);
106
110
 
107
- // Hide loading overlay when iframe sends SHELLUI_INITIALIZED
111
+ const scheduleReveal = (reveal: () => void) => {
112
+ const doReveal = () => {
113
+ const elapsed = Date.now() - mountTimeRef.current;
114
+ if (elapsed < MIN_LOADING_MS) {
115
+ const timer = setTimeout(doReveal, MIN_LOADING_MS - elapsed);
116
+ cancelRevealRef.current = () => {
117
+ clearTimeout(timer);
118
+ cancelRevealRef.current = null;
119
+ };
120
+ return;
121
+ }
122
+ reveal();
123
+ };
124
+ requestAnimationFrame(() => requestAnimationFrame(doReveal));
125
+ };
126
+
127
+ // Hide loading overlay when iframe sends SHELLUI_INITIALIZED.
128
+ // Defer reveal (double rAF + min time) so the iframe has time to apply theme and paint.
108
129
  useEffect(() => {
109
130
  const cleanup = shellui.addMessageListener(
110
131
  'SHELLUI_INITIALIZED',
111
132
  (_data: ShellUIMessage, event: MessageEvent) => {
112
- if (event.source === iframeRef.current?.contentWindow) {
113
- setIsLoading(false);
114
- }
133
+ if (event.source !== iframeRef.current?.contentWindow) return;
134
+ cancelRevealRef.current?.();
135
+ let cancelled = false;
136
+ cancelRevealRef.current = () => {
137
+ cancelled = true;
138
+ cancelRevealRef.current = null;
139
+ };
140
+ scheduleReveal(() => {
141
+ if (!cancelled) setIsLoading(false);
142
+ cancelRevealRef.current = null;
143
+ });
115
144
  },
116
145
  );
117
- return () => cleanup();
146
+ return () => {
147
+ cancelRevealRef.current?.();
148
+ cancelRevealRef.current = null;
149
+ cleanup();
150
+ };
118
151
  }, []);
119
152
 
120
- // Fallback: hide overlay after 400ms if SHELLUI_INITIALIZED was not received
153
+ // Fallback: hide overlay after 400ms if SHELLUI_INITIALIZED was not received.
121
154
  useEffect(() => {
122
155
  if (!isLoading) return;
123
156
  const timeoutId = setTimeout(() => {
124
157
  logger.info('ContentView: Timeout expired, hiding loading overlay');
125
- setIsLoading(false);
158
+ cancelRevealRef.current?.();
159
+ let cancelled = false;
160
+ cancelRevealRef.current = () => {
161
+ cancelled = true;
162
+ cancelRevealRef.current = null;
163
+ };
164
+ scheduleReveal(() => {
165
+ if (!cancelled) setIsLoading(false);
166
+ cancelRevealRef.current = null;
167
+ });
126
168
  }, 400);
127
169
  return () => clearTimeout(timeoutId);
128
170
  }, [isLoading]);
@@ -130,11 +172,10 @@ export const ContentView = ({
130
172
  // Handle external URL changes (e.g. from Sidebar)
131
173
  useEffect(() => {
132
174
  if (iframeRef.current && !isInternalNavigation.current) {
133
- // Only update iframe src if it's actually different from its current src
134
- // to avoid unnecessary reloads
135
175
  if (iframeRef.current.src !== url) {
136
176
  iframeRef.current.src = url;
137
177
  setIsLoading(true);
178
+ mountTimeRef.current = Date.now(); // apply min delay for this load too
138
179
  }
139
180
  }
140
181
  }, [url]);
@@ -234,19 +275,28 @@ export const ContentView = ({
234
275
  }, []);
235
276
 
236
277
  return (
237
- <div style={{ width: '100%', height: '100%', display: 'flex', position: 'relative' }}>
278
+ <div
279
+ style={{ width: '100%', height: '100%', display: 'flex', position: 'relative' }}
280
+ className="bg-background"
281
+ >
238
282
  {/* Note: allow-same-origin is required for same-origin iframe content (e.g., Vite dev server, cookies, localStorage).
239
283
  While this allows the iframe to remove its own sandboxing, it's acceptable here because the iframe content
240
284
  is trusted microfrontend content from the same application origin.
241
285
  Browser security warnings about this combination cannot be suppressed programmatically. */}
286
+ {/* Strategy to prevent browser deprioritizing iframe rendering:
287
+ - loading="eager" explicitly requests immediate loading (not deferred)
288
+ - opacity:0 hides the iframe during loading while keeping it in the rendering pipeline
289
+ - Reveal is instant (no transition) after deferred double-rAF to avoid blink */}
242
290
  <iframe
243
291
  ref={iframeRef}
244
292
  src={initialUrl}
293
+ loading="eager"
245
294
  style={{
246
295
  width: '100%',
247
296
  height: '100%',
248
297
  border: 'none',
249
298
  display: 'block',
299
+ opacity: isLoading ? 0 : 1,
250
300
  }}
251
301
  sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
252
302
  referrerPolicy="no-referrer-when-downgrade"
@@ -1,6 +1,6 @@
1
1
  export function LoadingOverlay() {
2
2
  return (
3
- <div className="absolute inset-0 z-10 flex flex-col bg-background">
3
+ <div className="absolute inset-x-0 top-0 z-10">
4
4
  <div className="h-1 w-full overflow-hidden bg-muted/30">
5
5
  <div
6
6
  className="h-full w-0 bg-muted-foreground/50"
@@ -61,8 +61,7 @@ function TopBarEndItem({
61
61
  const pathPrefix = getNavPathPrefix(item);
62
62
  const isOverlay = item.openIn === 'modal' || item.openIn === 'drawer';
63
63
  const isExternal = item.openIn === 'external';
64
- const isActive =
65
- !isOverlay && !isExternal && pathPrefix === activePathPrefix;
64
+ const isActive = !isOverlay && !isExternal && pathPrefix === activePathPrefix;
66
65
 
67
66
  const faviconUrl = isExternal && !item.icon ? getExternalFaviconUrl(item.url) : null;
68
67
  const iconSrc = item.icon ?? faviconUrl ?? null;
@@ -99,8 +99,7 @@ const NavigationContent = ({
99
99
  const pathPrefix = getNavPathPrefix(navItem);
100
100
  const isOverlay = navItem.openIn === 'modal' || navItem.openIn === 'drawer';
101
101
  const isExternal = navItem.openIn === 'external';
102
- const isActive =
103
- !isOverlay && !isExternal && pathPrefix === activePathPrefix;
102
+ const isActive = !isOverlay && !isExternal && pathPrefix === activePathPrefix;
104
103
  const itemLabel = resolveLocalizedString(navItem.label, currentLanguage);
105
104
  const faviconUrl = isExternal && !navItem.icon ? getExternalFaviconUrl(navItem.url) : null;
106
105
  const iconSrc = navItem.icon ?? faviconUrl ?? null;
@@ -6,11 +6,13 @@ import {
6
6
  useEffect,
7
7
  type PointerEvent as ReactPointerEvent,
8
8
  } from 'react';
9
+ import { useLocation } from 'react-router';
9
10
  import { useTranslation } from 'react-i18next';
10
11
  import { shellui } from '@shellui/sdk';
11
12
  import type { NavigationItem, NavigationGroup } from '../config/types';
12
13
  import {
13
14
  flattenNavigationItems,
15
+ getActivePathPrefix,
14
16
  getNavPathPrefix,
15
17
  resolveLocalizedString as resolveNavLabel,
16
18
  splitNavigationByPosition,
@@ -121,13 +123,16 @@ function AppWindow({
121
123
  const resizeRafRef = useRef<number | null>(null);
122
124
  const pendingResizeBoundsRef = useRef<WindowState['bounds'] | null>(null);
123
125
 
124
- useEffect(() => {
125
- setBounds(win.bounds);
126
- }, [win.bounds]);
126
+ // Use a ref for onBoundsChange to avoid it in effect deps (prevents infinite render loop).
127
+ // The parent creates a new callback reference on every render (inline arrow), so including
128
+ // it in a dependency array would re-fire the effect every render, triggering a state update
129
+ // in the parent, which re-renders, which re-fires the effect → React error #185.
130
+ const onBoundsChangeRef = useRef(onBoundsChange);
131
+ onBoundsChangeRef.current = onBoundsChange;
127
132
 
128
133
  useEffect(() => {
129
- onBoundsChange(bounds);
130
- }, [bounds, onBoundsChange]);
134
+ onBoundsChangeRef.current(bounds);
135
+ }, [bounds]);
131
136
 
132
137
  // When maximized, keep filling the viewport on window resize
133
138
  useEffect(() => {
@@ -504,6 +509,7 @@ export function WindowsLayout({
504
509
  logo: _logo,
505
510
  navigation,
506
511
  }: WindowsLayoutProps) {
512
+ const location = useLocation();
507
513
  const { i18n } = useTranslation();
508
514
  const { settings } = useSettings();
509
515
  const currentLanguage = i18n.language || 'en';
@@ -523,6 +529,7 @@ export function WindowsLayout({
523
529
  const [startMenuOpen, setStartMenuOpen] = useState(false);
524
530
  const [now, setNow] = useState(() => new Date());
525
531
  const startPanelRef = useRef<HTMLDivElement>(null);
532
+ const initialOpenFromUrlDoneRef = useRef(false);
526
533
 
527
534
  // Update date/time every second for taskbar clock
528
535
  useEffect(() => {
@@ -568,6 +575,20 @@ export function WindowsLayout({
568
575
  [currentLanguage, windows.length],
569
576
  );
570
577
 
578
+ // On first load only: open a window for the current URL if it matches a nav item (no reaction to later URL changes)
579
+ useEffect(() => {
580
+ if (initialOpenFromUrlDoneRef.current) return;
581
+ initialOpenFromUrlDoneRef.current = true;
582
+ const pathname = location.pathname;
583
+ const windowableItems = navigationItems.filter(
584
+ (i) => i.openIn !== 'modal' && i.openIn !== 'drawer' && i.openIn !== 'external',
585
+ );
586
+ const pathPrefix = getActivePathPrefix(pathname, windowableItems);
587
+ if (!pathPrefix) return;
588
+ const item = windowableItems.find((i) => getNavPathPrefix(i) === pathPrefix);
589
+ if (item) openWindow(item);
590
+ }, [location.pathname, navigationItems, openWindow]);
591
+
571
592
  const closeWindow = useCallback((id: string) => {
572
593
  setWindows((prev) => prev.filter((w) => w.id !== id));
573
594
  setFrontWindowId((current) => (current === id ? null : current));
@@ -6,18 +6,13 @@ export function getNavPathPrefix(item: NavigationItem): string {
6
6
  }
7
7
 
8
8
  /** Among items that match the current pathname, return the longest path prefix. Used so only one nav item is active when URLs nest (e.g. /foo and /foo/bar). */
9
- export function getActivePathPrefix(
10
- pathname: string,
11
- items: NavigationItem[],
12
- ): string | null {
9
+ export function getActivePathPrefix(pathname: string, items: NavigationItem[]): string | null {
13
10
  const linkItems = items.filter(
14
11
  (i) => i.openIn !== 'modal' && i.openIn !== 'drawer' && i.openIn !== 'external',
15
12
  );
16
13
  const matching = linkItems
17
14
  .map((i) => getNavPathPrefix(i))
18
- .filter(
19
- (p) => pathname === p || pathname.startsWith(p === '/' ? '/' : p + '/'),
20
- );
15
+ .filter((p) => pathname === p || pathname.startsWith(p === '/' ? '/' : `${p}/`));
21
16
  if (matching.length === 0) return null;
22
17
  return matching.reduce((a, b) => (a.length >= b.length ? a : b));
23
18
  }
@@ -6,12 +6,13 @@ import {
6
6
  type Settings,
7
7
  type SettingsNavigationItem,
8
8
  type Appearance,
9
+ type SettingsAvailableTheme,
9
10
  } from '@shellui/sdk';
10
11
  import { SettingsContext } from './SettingsContext';
11
12
  import { useConfig } from '../config/useConfig';
12
13
  import { useTranslation } from 'react-i18next';
13
14
  import type { NavigationItem, NavigationGroup, ShellUIConfig } from '../config/types';
14
- import { getTheme, registerTheme } from '../theme/themes';
15
+ import { getTheme, getAllThemes, registerTheme } from '../theme/themes';
15
16
 
16
17
  const logger = getLogger('shellcore');
17
18
 
@@ -40,6 +41,20 @@ function resolveColorMode(colorScheme: 'light' | 'dark' | 'system'): 'light' | '
40
41
  return colorScheme === 'dark' ? 'dark' : 'light';
41
42
  }
42
43
 
44
+ /** Convert font file URLs to absolute so iframes/modals on other ports or domains can load them. */
45
+ function toAbsoluteFontUrls(urls: string[]): string[] {
46
+ if (typeof window === 'undefined') return urls;
47
+ const origin = window.location.origin;
48
+ return urls.map((url) => {
49
+ const trimmed = url.trim();
50
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
51
+ return trimmed;
52
+ }
53
+ const path = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
54
+ return `${origin}${path}`;
55
+ });
56
+ }
57
+
43
58
  /**
44
59
  * Build the full appearance object for settings propagation so apps receive all theme
45
60
  * variable values and can style without knowing the theme name.
@@ -50,8 +65,7 @@ function getResolvedAppearanceForSettings(
50
65
  ): Appearance | undefined {
51
66
  if (typeof window === 'undefined') return undefined;
52
67
  config?.themes?.forEach(registerTheme);
53
- const themeName =
54
- settings.appearance?.name || config?.defaultTheme || 'default';
68
+ const themeName = settings.appearance?.name || config?.defaultTheme || 'default';
55
69
  const themeDef = getTheme(themeName) || getTheme('default');
56
70
  if (!themeDef) return undefined;
57
71
  const colorScheme = settings.appearance?.colorScheme ?? 'system';
@@ -75,13 +89,29 @@ function getResolvedAppearanceForSettings(
75
89
  ...(themeDef.textShadow !== undefined && { textShadow: themeDef.textShadow }),
76
90
  ...(themeDef.lineHeight !== undefined && { lineHeight: themeDef.lineHeight }),
77
91
  ...(themeDef.fontFiles !== undefined &&
78
- themeDef.fontFiles.length > 0 && { fontFiles: themeDef.fontFiles }),
92
+ themeDef.fontFiles.length > 0 && {
93
+ fontFiles: toAbsoluteFontUrls(themeDef.fontFiles),
94
+ }),
79
95
  };
80
96
  }
81
97
 
82
98
  /**
83
- * Build settings for propagation to iframes: inject navigation and full theme object
84
- * so apps receive all theme variable values.
99
+ * Map registered themes to the slim shape sent to sub-apps (name, displayName, colors, optional typography for preview).
100
+ */
101
+ function getAvailableThemesForSettings(): SettingsAvailableTheme[] {
102
+ return getAllThemes().map((theme) => ({
103
+ name: theme.name,
104
+ displayName: theme.displayName,
105
+ colors: theme.colors,
106
+ ...(theme.fontFamily !== undefined && { fontFamily: theme.fontFamily }),
107
+ ...(theme.letterSpacing !== undefined && { letterSpacing: theme.letterSpacing }),
108
+ ...(theme.textShadow !== undefined && { textShadow: theme.textShadow }),
109
+ }));
110
+ }
111
+
112
+ /**
113
+ * Build settings for propagation to iframes: inject navigation, full theme object,
114
+ * and list of available themes so apps can render theme pickers.
85
115
  */
86
116
  function buildSettingsForPropagation(
87
117
  settings: Settings,
@@ -93,14 +123,24 @@ function buildSettingsForPropagation(
93
123
  ...settings,
94
124
  appearance: appearance ?? settings.appearance,
95
125
  };
126
+ // Inject available themes when we have a resolved appearance (themes are already registered above)
127
+ if (result.appearance && typeof window !== 'undefined') {
128
+ result = {
129
+ ...result,
130
+ appearance: {
131
+ ...result.appearance,
132
+ availableThemes: getAvailableThemesForSettings(),
133
+ },
134
+ };
135
+ }
96
136
  if (config?.navigation?.length) {
97
- const items: SettingsNavigationItem[] = flattenNavigationItems(
98
- config.navigation,
99
- ).map((item) => ({
100
- path: item.path,
101
- url: item.url,
102
- label: resolveLabel(item.label, lang),
103
- }));
137
+ const items: SettingsNavigationItem[] = flattenNavigationItems(config.navigation).map(
138
+ (item) => ({
139
+ path: item.path,
140
+ url: item.url,
141
+ label: resolveLabel(item.label, lang),
142
+ }),
143
+ );
104
144
  result = { ...result, navigation: { items } };
105
145
  }
106
146
  return result;
@@ -251,9 +291,12 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
251
291
  ...defaultAppearance,
252
292
  ...parsed.appearance,
253
293
  // Migrate from legacy theme/themeName
254
- name: parsed.appearance?.name ?? parsed.appearance?.themeName ?? defaultAppearance.name,
294
+ name:
295
+ parsed.appearance?.name ?? parsed.appearance?.themeName ?? defaultAppearance.name,
255
296
  colorScheme:
256
- parsed.appearance?.colorScheme ?? parsed.appearance?.theme ?? defaultAppearance.colorScheme,
297
+ parsed.appearance?.colorScheme ??
298
+ parsed.appearance?.theme ??
299
+ defaultAppearance.colorScheme,
257
300
  colors: parsed.appearance?.colors ?? defaultAppearance.colors,
258
301
  },
259
302
  language: {
@@ -4,8 +4,9 @@ import { useConfig } from '../../config/useConfig';
4
4
  import { Button } from '../../../components/ui/button';
5
5
  import { ButtonGroup } from '../../../components/ui/button-group';
6
6
  import { cn } from '../../../lib/utils';
7
- import { useEffect, useState } from 'react';
7
+ import { useEffect, useState, useMemo } from 'react';
8
8
  import { getAllThemes, registerTheme, type ThemeDefinition } from '../../theme/themes';
9
+ import type { SettingsAvailableTheme } from '@shellui/sdk';
9
10
 
10
11
  const SunIcon = () => (
11
12
  <svg
@@ -85,13 +86,19 @@ const MonitorIcon = () => (
85
86
  </svg>
86
87
  );
87
88
 
89
+ /** Theme-like shape used for preview (ThemeDefinition or SettingsAvailableTheme). */
90
+ type ThemePreviewItem = Pick<
91
+ ThemeDefinition | SettingsAvailableTheme,
92
+ 'name' | 'displayName' | 'colors' | 'fontFamily' | 'letterSpacing' | 'textShadow'
93
+ >;
94
+
88
95
  // Theme color preview component
89
96
  const ThemePreview = ({
90
97
  theme,
91
98
  isSelected,
92
99
  isDark,
93
100
  }: {
94
- theme: ThemeDefinition;
101
+ theme: ThemePreviewItem;
95
102
  isSelected: boolean;
96
103
  isDark: boolean;
97
104
  }) => {
@@ -169,18 +176,25 @@ export const Appearance = () => {
169
176
  const currentTheme = settings.appearance?.colorScheme ?? 'system';
170
177
  const currentThemeName = settings.appearance?.name ?? 'default';
171
178
 
172
- const [availableThemes, setAvailableThemes] = useState<ThemeDefinition[]>([]);
179
+ const [localThemes, setLocalThemes] = useState<ThemeDefinition[]>([]);
173
180
 
174
- // Register custom themes from config and get all themes
181
+ // Register custom themes from config and get all themes (for shell context)
175
182
  useEffect(() => {
176
183
  if (config?.themes) {
177
184
  config.themes.forEach((themeDef: ThemeDefinition) => {
178
185
  registerTheme(themeDef);
179
186
  });
180
187
  }
181
- setAvailableThemes(getAllThemes());
188
+ setLocalThemes(getAllThemes());
182
189
  }, [config]);
183
190
 
191
+ // Use availableThemes from settings when provided (e.g. from shell when in sub-app), else local registry
192
+ const availableThemes = useMemo((): ThemePreviewItem[] => {
193
+ const fromSettings = settings.appearance?.availableThemes;
194
+ if (fromSettings?.length) return fromSettings;
195
+ return localThemes;
196
+ }, [settings.appearance?.availableThemes, localThemes]);
197
+
184
198
  // Determine if we're in dark mode for preview
185
199
  const [isDarkForPreview, setIsDarkForPreview] = useState(() => {
186
200
  if (typeof window === 'undefined') return false;