@shellui/core 0.2.0-alpha.4 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shellui/core",
3
- "version": "0.2.0-alpha.4",
3
+ "version": "0.2.0-beta.0",
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.4"
61
+ "@shellui/sdk": "0.2.0-beta.0"
62
62
  },
63
63
  "peerDependencies": {
64
64
  "react": "^18.0.0 || ^19.0.0",
package/src/app.tsx CHANGED
@@ -33,7 +33,7 @@ const AppContent = () => {
33
33
  unregisterServiceWorker();
34
34
  return;
35
35
  }
36
- const serviceWorkerEnabled = settings?.serviceWorker?.enabled ?? true; // Default to enabled
36
+ const serviceWorkerEnabled = settings?.serviceWorker?.enabled ?? false; // Default to enabled
37
37
 
38
38
  // Don't register service worker if navigation is empty or undefined
39
39
  // This helps prevent issues in development or misconfigured apps
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable no-console */
2
2
  import type { NavigationItem } from '../features/config/types';
3
+ import { isHashRouterNavItem, getHashPathFromUrl } from '../features/layouts/utils';
3
4
  import {
4
5
  addIframe,
5
6
  removeIframe,
@@ -10,10 +11,14 @@ import {
10
11
  } from '@shellui/sdk';
11
12
  import { useEffect, useRef, useState } from 'react';
12
13
  import { useNavigate } from 'react-router';
14
+ import { LOADING_OVERLAY_DURATION_MS } from '../constants';
13
15
  import { LoadingOverlay } from './LoadingOverlay';
14
16
 
15
17
  const logger = getLogger('shellcore');
16
18
 
19
+ /** URL of the last main-content iframe that sent SHELLUI_INITIALIZED. Used to skip the loading overlay when navigating between nav items that point to the same app URL. */
20
+ let lastLoadedIframeUrl: string | null = null;
21
+
17
22
  interface ContentViewProps {
18
23
  url: string;
19
24
  pathPrefix: string;
@@ -29,11 +34,16 @@ export const ContentView = ({
29
34
  }: ContentViewProps) => {
30
35
  const navigate = useNavigate();
31
36
  const iframeRef = useRef<HTMLIFrameElement>(null);
32
- const isInternalNavigation = useRef(false);
33
37
  const cancelRevealRef = useRef<(() => void) | null>(null);
34
38
  const mountTimeRef = useRef(Date.now());
39
+ const urlRef = useRef(url);
40
+ urlRef.current = url;
35
41
  const [initialUrl] = useState(url);
36
- const [isLoading, setIsLoading] = useState(true);
42
+ const [isLoading, setIsLoading] = useState(() => {
43
+ // Skip overlay when same app URL was just loaded (e.g. switching App ↔ Root with same url)
44
+ if (!ignoreMessages && url === lastLoadedIframeUrl) return false;
45
+ return true;
46
+ });
37
47
 
38
48
  const MIN_LOADING_MS = 80; // Don't reveal before this, reduces blink from theme/layout paint
39
49
 
@@ -62,23 +72,44 @@ export const ContentView = ({
62
72
  }
63
73
 
64
74
  const { pathname, search, hash } = data.payload as ShellUIUrlPayload;
65
- // Remove leading slash and trailing slashes from iframe pathname
66
- let cleanPathname = pathname.startsWith(navItem.url)
67
- ? pathname.slice(navItem.url.length)
68
- : pathname;
69
- cleanPathname = cleanPathname.startsWith('/') ? cleanPathname.slice(1) : cleanPathname;
70
- cleanPathname = cleanPathname.replace(/\/+$/, ''); // Remove trailing slashes
71
- // Construct the new path without trailing slashes
72
- let newShellPath = cleanPathname
73
- ? `/${pathPrefix}/${cleanPathname}${search}${hash}`
74
- : `/${pathPrefix}${search}${hash}`;
75
+ // Shell URL is always path + search only (no hash) so it's transparent whether the sub-app uses hash routing or not
76
+ let pathSegment: string;
77
+ if (isHashRouterNavItem(navItem) && hash) {
78
+ // Hash-router app: use path relative to nav item's hash (e.g. nav #/themes, iframe #/themes → segment ''; iframe #/themes/foo → segment 'foo')
79
+ const iframeHashPath = hash.replace(/^#\/?/, '').replace(/\/+$/, '') || '';
80
+ const navHashPath = getHashPathFromUrl(navItem.url).replace(/^\/+|\/+$/g, '');
81
+ const relative = navHashPath
82
+ ? iframeHashPath === navHashPath || iframeHashPath.startsWith(`${navHashPath}/`)
83
+ ? iframeHashPath.slice(navHashPath.length).replace(/^\//, '')
84
+ : iframeHashPath
85
+ : iframeHashPath;
86
+ pathSegment = relative;
87
+ } else {
88
+ // Non-hash app: route is pathname
89
+ let cleanPathname = pathname.startsWith(navItem.url)
90
+ ? pathname.slice(navItem.url.length)
91
+ : pathname;
92
+ cleanPathname = cleanPathname.startsWith('/') ? cleanPathname.slice(1) : cleanPathname;
93
+ pathSegment = cleanPathname.replace(/\/+$/, '');
94
+ }
95
+ // Root (pathPrefix '' or '/') must produce /segment not //segment
96
+ const isRoot = pathPrefix === '' || pathPrefix === '/';
97
+ let newShellPath = isRoot
98
+ ? pathSegment
99
+ ? `/${pathSegment}${search}`
100
+ : search
101
+ ? `/${search}`
102
+ : '/'
103
+ : pathSegment
104
+ ? `/${pathPrefix}/${pathSegment}${search}`
105
+ : `/${pathPrefix}${search}`;
75
106
 
76
- // Normalize: remove trailing slashes from pathname part only (preserve query/hash)
107
+ // Normalize: remove trailing slashes from pathname part only (preserve query)
77
108
  const urlParts = newShellPath.match(/^([^?#]*)([?#].*)?$/);
78
109
  if (urlParts) {
79
110
  const pathnamePart = urlParts[1].replace(/\/+$/, '') || '/';
80
- const queryHashPart = urlParts[2] || '';
81
- newShellPath = pathnamePart + queryHashPart;
111
+ const queryPart = urlParts[2] || '';
112
+ newShellPath = pathnamePart + queryPart;
82
113
  }
83
114
 
84
115
  // Normalize current path for comparison (remove trailing slashes from pathname)
@@ -91,14 +122,7 @@ export const ContentView = ({
91
122
  const normalizedNewPath = normalizedNewPathname + (newPathParts?.[2] || '');
92
123
 
93
124
  if (currentPath !== normalizedNewPath) {
94
- // Mark this navigation as internal so we don't try to "push" it back to the iframe
95
- isInternalNavigation.current = true;
96
- navigate(newShellPath, { replace: true });
97
-
98
- // Reset the flag after a short delay to allow the render cycle to complete
99
- setTimeout(() => {
100
- isInternalNavigation.current = false;
101
- }, 100);
125
+ navigate(newShellPath);
102
126
  }
103
127
  },
104
128
  );
@@ -106,7 +130,7 @@ export const ContentView = ({
106
130
  return () => {
107
131
  cleanup();
108
132
  };
109
- }, [pathPrefix, navigate]);
133
+ }, [pathPrefix, navigate, navItem]);
110
134
 
111
135
  const scheduleReveal = (reveal: () => void) => {
112
136
  const doReveal = () => {
@@ -126,11 +150,13 @@ export const ContentView = ({
126
150
 
127
151
  // Hide loading overlay when iframe sends SHELLUI_INITIALIZED.
128
152
  // Defer reveal (double rAF + min time) so the iframe has time to apply theme and paint.
153
+ // Remember this URL so we can skip the overlay when navigating to the same app (e.g. App ↔ Root).
129
154
  useEffect(() => {
130
155
  const cleanup = shellui.addMessageListener(
131
156
  'SHELLUI_INITIALIZED',
132
157
  (_data: ShellUIMessage, event: MessageEvent) => {
133
158
  if (event.source !== iframeRef.current?.contentWindow) return;
159
+ if (!ignoreMessages) lastLoadedIframeUrl = urlRef.current;
134
160
  cancelRevealRef.current?.();
135
161
  let cancelled = false;
136
162
  cancelRevealRef.current = () => {
@@ -148,9 +174,9 @@ export const ContentView = ({
148
174
  cancelRevealRef.current = null;
149
175
  cleanup();
150
176
  };
151
- }, []);
177
+ }, [ignoreMessages]);
152
178
 
153
- // Fallback: hide overlay after 400ms if SHELLUI_INITIALIZED was not received.
179
+ // Fallback: hide overlay after LOADING_OVERLAY_DURATION_MS if SHELLUI_INITIALIZED was not received.
154
180
  useEffect(() => {
155
181
  if (!isLoading) return;
156
182
  const timeoutId = setTimeout(() => {
@@ -165,83 +191,24 @@ export const ContentView = ({
165
191
  if (!cancelled) setIsLoading(false);
166
192
  cancelRevealRef.current = null;
167
193
  });
168
- }, 400);
194
+ }, LOADING_OVERLAY_DURATION_MS);
169
195
  return () => clearTimeout(timeoutId);
170
196
  }, [isLoading]);
171
197
 
172
198
  // Handle external URL changes (e.g. from Sidebar)
173
199
  useEffect(() => {
174
- if (iframeRef.current && !isInternalNavigation.current) {
200
+ if (iframeRef.current) {
175
201
  if (iframeRef.current.src !== url) {
176
202
  iframeRef.current.src = url;
177
- setIsLoading(true);
178
- mountTimeRef.current = Date.now(); // apply min delay for this load too
179
- }
180
- }
181
- }, [url]);
182
-
183
- // Inject script to prevent "Layout was forced" warning by deferring layout until stylesheets load
184
- useEffect(() => {
185
- const iframe = iframeRef.current;
186
- if (!iframe) return;
187
-
188
- const handleLoad = () => {
189
- try {
190
- const iframeWindow = iframe.contentWindow;
191
- const iframeDoc = iframe.contentDocument || iframeWindow?.document;
192
- if (!iframeDoc || !iframeWindow) return;
193
-
194
- // Inject a script that waits for stylesheets before allowing layout calculations
195
- const script = iframeDoc.createElement('script');
196
- script.textContent = `
197
- (function() {
198
- // Wait for all stylesheets to load
199
- function waitForStylesheets() {
200
- const styleSheets = Array.from(document.styleSheets);
201
- const pendingSheets = styleSheets.filter(function(sheet) {
202
- try {
203
- return sheet.cssRules === null;
204
- } catch (e) {
205
- return false; // Cross-origin stylesheets, assume loaded
206
- }
207
- });
208
-
209
- if (pendingSheets.length === 0) {
210
- // All stylesheets loaded
211
- return;
212
- }
213
-
214
- // Check again after a short delay
215
- setTimeout(waitForStylesheets, 10);
216
- }
217
-
218
- // Start checking after DOM is ready
219
- if (document.readyState === 'complete') {
220
- waitForStylesheets();
221
- } else {
222
- window.addEventListener('load', waitForStylesheets);
223
- }
224
- })();
225
- `;
226
- iframeDoc.head.appendChild(script);
227
- } catch (error) {
228
- // Cross-origin or other errors - ignore (this is expected for some iframes)
229
- logger.debug('Could not inject stylesheet wait script:', { error });
203
+ // Skip overlay when switching to the same app URL (e.g. App ↔ Root); different app still shows overlay
204
+ const sameAppAlreadyLoaded = !ignoreMessages;
205
+ if (!sameAppAlreadyLoaded) {
206
+ setIsLoading(true);
207
+ mountTimeRef.current = Date.now(); // apply min delay for this load too
208
+ }
230
209
  }
231
- };
232
-
233
- // Wait for iframe to load before injecting script
234
- iframe.addEventListener('load', handleLoad);
235
-
236
- // Also try immediately if already loaded
237
- if (iframe.contentDocument?.readyState === 'complete') {
238
- handleLoad();
239
210
  }
240
-
241
- return () => {
242
- iframe.removeEventListener('load', handleLoad);
243
- };
244
- }, [initialUrl]);
211
+ }, [url, ignoreMessages]);
245
212
 
246
213
  // Suppress browser warnings that are expected and acceptable
247
214
  useEffect(() => {
@@ -1,10 +1,14 @@
1
+ import { LOADING_OVERLAY_DURATION_MS } from '../constants';
2
+
1
3
  export function LoadingOverlay() {
2
4
  return (
3
5
  <div className="absolute inset-x-0 top-0 z-10">
4
6
  <div className="h-1 w-full overflow-hidden bg-muted/30">
5
7
  <div
6
8
  className="h-full w-0 bg-muted-foreground/50"
7
- style={{ animation: 'loading-bar-slide 400ms linear infinite' }}
9
+ style={{
10
+ animation: `loading-bar-slide ${LOADING_OVERLAY_DURATION_MS}ms linear infinite`,
11
+ }}
8
12
  />
9
13
  </div>
10
14
  </div>
@@ -37,7 +37,12 @@ export const NotFoundView = () => {
37
37
  : [];
38
38
 
39
39
  const handleNavigate = (path: string) => {
40
- shellui.navigate(path.startsWith('/') ? path : `/${path}`);
40
+ const targetPath = path.startsWith('/') ? path : `/${path}`;
41
+ if (window.self !== window.top) {
42
+ shellui.navigate(targetPath);
43
+ } else {
44
+ window.location.href = targetPath;
45
+ }
41
46
  };
42
47
 
43
48
  return (
@@ -1,6 +1,11 @@
1
1
  import { useMemo } from 'react';
2
2
  import { Navigate, useLocation } from 'react-router';
3
- import { getNavPathPrefix } from '../features/layouts/utils';
3
+ import {
4
+ getNavPathPrefix,
5
+ isHashRouterNavItem,
6
+ getBaseUrlWithoutHash,
7
+ getHashPathFromUrl,
8
+ } from '../features/layouts/utils';
4
9
  import { ContentView } from './ContentView';
5
10
  import type { NavigationItem } from '../features/config/types';
6
11
 
@@ -19,7 +24,22 @@ export const ViewRoute = ({ navigation }: ViewRouteProps) => {
19
24
  });
20
25
  }, [navigation, pathname]);
21
26
 
22
- if (!navItem) {
27
+ // When no nav matches (e.g. /layout on refresh): use root item (path '' or '/') with pathname as hash subpath to avoid 404
28
+ const rootItem = useMemo(
29
+ () => navigation.find((item) => item.path === '' || item.path === '/'),
30
+ [navigation],
31
+ );
32
+ const useRootFallback = !navItem && rootItem && pathname !== '/';
33
+ const actualNavItem = navItem ?? (useRootFallback ? rootItem : null);
34
+ const actualSubPath = useRootFallback
35
+ ? pathname.replace(/^\//, '')
36
+ : actualNavItem
37
+ ? pathname.length > getNavPathPrefix(actualNavItem).length
38
+ ? pathname.slice(getNavPathPrefix(actualNavItem).length + 1)
39
+ : ''
40
+ : '';
41
+
42
+ if (!actualNavItem) {
23
43
  return (
24
44
  <Navigate
25
45
  to="/"
@@ -27,22 +47,28 @@ export const ViewRoute = ({ navigation }: ViewRouteProps) => {
27
47
  />
28
48
  );
29
49
  }
30
- // Calculate the relative path from the navItem.path
31
- // e.g. if item.path is "docs" and pathname is "/docs/intro", subPath is "intro"
32
- const pathPrefix = getNavPathPrefix(navItem);
33
- const subPath = pathname.length > pathPrefix.length ? pathname.slice(pathPrefix.length + 1) : '';
50
+ const subPath = actualSubPath;
34
51
 
35
- // Construct the final URL for the iframe
36
- let finalUrl = navItem.url;
37
- if (subPath) {
38
- const baseUrl = navItem.url.endsWith('/') ? navItem.url : `${navItem.url}/`;
39
- finalUrl = `${baseUrl}${subPath}`;
52
+ // Construct the final URL for the iframe (non-hash: base + path; hash app: preserve nav url hash path + subPath)
53
+ let finalUrl: string;
54
+ if (isHashRouterNavItem(actualNavItem)) {
55
+ const base = getBaseUrlWithoutHash(actualNavItem.url).replace(/\/$/, '');
56
+ const navHashPath = getHashPathFromUrl(actualNavItem.url).replace(/^\/+|\/+$/g, '');
57
+ const segments = [navHashPath, subPath].filter(Boolean);
58
+ const fullHashPath = `/${segments.join('/')}`;
59
+ finalUrl = `${base}#${fullHashPath}`;
60
+ } else {
61
+ finalUrl = actualNavItem.url;
62
+ if (subPath) {
63
+ const baseUrl = actualNavItem.url.endsWith('/') ? actualNavItem.url : `${actualNavItem.url}/`;
64
+ finalUrl = `${baseUrl}${subPath}`;
65
+ }
40
66
  }
41
67
  return (
42
68
  <ContentView
43
69
  url={finalUrl}
44
- pathPrefix={navItem.path}
45
- navItem={navItem}
70
+ pathPrefix={actualNavItem.path}
71
+ navItem={actualNavItem}
46
72
  />
47
73
  );
48
74
  };
@@ -0,0 +1,2 @@
1
+ /** Duration (ms) for loading overlay: fallback timeout and bar animation. */
2
+ export const LOADING_OVERLAY_DURATION_MS = 1000;
@@ -26,6 +26,8 @@ export interface NavigationItem {
26
26
  hiddenOnDesktop?: boolean;
27
27
  /** How to open this link: 'default' (navigate in main area), 'modal', 'drawer', or 'external' (target="_blank"). */
28
28
  openIn?: 'default' | 'modal' | 'drawer' | 'external';
29
+ /** When true, the app uses hash-based routing (e.g. /#/path). If omitted, inferred from url containing /#/. */
30
+ useHashRouter?: boolean;
29
31
  /** Optional drawer position when openIn === 'drawer'. Default is 'right' if omitted. */
30
32
  drawerPosition?: DrawerPosition;
31
33
  /** Sidebar position: 'start' (default) or 'end'. End items are rendered in the sidebar footer. */
@@ -433,13 +433,16 @@ const HomeIcon = ({ className }: { className?: string }) => (
433
433
  </svg>
434
434
  );
435
435
 
436
- /** Mobile bottom nav: Home + nav items; More only when not all fit. Dynamic from width. Reuses HOMEPAGE_NAV_ITEM for label. */
436
+ /** Mobile bottom nav: optional Home + nav items; More only when not all fit. Dynamic from width. Reuses HOMEPAGE_NAV_ITEM for label. Home is shown only when no nav item is defined for "/". */
437
437
  const MobileBottomNav = ({
438
438
  items,
439
439
  currentLanguage,
440
+ showHomeButton,
440
441
  }: {
441
442
  items: NavigationItem[];
442
443
  currentLanguage: string;
444
+ /** When false, do not show the Home button (e.g. when a nav item for "/" exists). */
445
+ showHomeButton: boolean;
443
446
  }) => {
444
447
  const location = useLocation();
445
448
  const [expanded, setExpanded] = useState(false);
@@ -470,9 +473,11 @@ const MobileBottomNav = ({
470
473
  const computedSlots =
471
474
  rowWidth > 0 ? Math.floor((contentWidth + BOTTOM_NAV_GAP) / slotTotal) : 5;
472
475
  const totalSlots = Math.min(Math.max(0, computedSlots), BOTTOM_NAV_MAX_SLOTS);
473
- const slotsForNav = totalSlots - 1;
476
+ const slotsForNav = showHomeButton ? totalSlots - 1 : totalSlots;
474
477
  const allFit = list.length <= slotsForNav;
475
- const maxInRow = allFit ? list.length : Math.max(0, totalSlots - 2);
478
+ const maxInRow = allFit
479
+ ? list.length
480
+ : Math.max(0, showHomeButton ? totalSlots - 2 : totalSlots - 1);
476
481
  const row = list.slice(0, maxInRow);
477
482
  const rowPaths = new Set(row.map((i) => i.path));
478
483
  const overflow = list.filter((item) => !rowPaths.has(item.path));
@@ -481,7 +486,7 @@ const MobileBottomNav = ({
481
486
  overflowItems: overflow,
482
487
  hasMore: overflow.length > 0,
483
488
  };
484
- }, [items, rowWidth]);
489
+ }, [items, rowWidth, showHomeButton]);
485
490
 
486
491
  useEffect(() => {
487
492
  setExpanded(false);
@@ -518,25 +523,27 @@ const MobileBottomNav = ({
518
523
  paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
519
524
  }}
520
525
  >
521
- {/* Top row: Home + nav items + More/Less — single row, no wrap */}
526
+ {/* Top row: optional Home + nav items + More/Less — single row, no wrap */}
522
527
  <div className="flex flex-row flex-nowrap items-center justify-center gap-1 px-3 overflow-x-hidden">
523
- <Link
524
- to="/"
525
- className={cn(
526
- 'flex flex-col items-center justify-center gap-1 rounded-md py-1.5 px-2 min-w-0 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
527
- location.pathname === '/' || location.pathname === ''
528
- ? 'bg-sidebar-accent text-sidebar-accent-foreground [&_span]:text-sidebar-accent-foreground'
529
- : 'text-sidebar-foreground/80 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground [&_span]:inherit',
530
- )}
531
- aria-label={resolveNavLabel(HOMEPAGE_NAV_ITEM.label, currentLanguage) || 'Home'}
532
- >
533
- <span className="size-4 shrink-0 flex items-center justify-center [&_svg]:text-current">
534
- <HomeIcon className="size-4" />
535
- </span>
536
- <span className="text-[11px] leading-tight">
537
- {resolveNavLabel(HOMEPAGE_NAV_ITEM.label, currentLanguage) || 'Home'}
538
- </span>
539
- </Link>
528
+ {showHomeButton && (
529
+ <Link
530
+ to="/"
531
+ className={cn(
532
+ 'flex flex-col items-center justify-center gap-1 rounded-md py-1.5 px-2 min-w-0 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
533
+ location.pathname === '/' || location.pathname === ''
534
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground [&_span]:text-sidebar-accent-foreground'
535
+ : 'text-sidebar-foreground/80 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground [&_span]:inherit',
536
+ )}
537
+ aria-label={resolveNavLabel(HOMEPAGE_NAV_ITEM.label, currentLanguage) || 'Home'}
538
+ >
539
+ <span className="size-4 shrink-0 flex items-center justify-center [&_svg]:text-current">
540
+ <HomeIcon className="size-4" />
541
+ </span>
542
+ <span className="text-[11px] leading-tight">
543
+ {resolveNavLabel(HOMEPAGE_NAV_ITEM.label, currentLanguage) || 'Home'}
544
+ </span>
545
+ </Link>
546
+ )}
540
547
  {rowItems.map((item, i) => renderItem(item, i))}
541
548
  {hasMore && (
542
549
  <button
@@ -581,17 +588,19 @@ const DefaultLayoutContent = ({ title, logo, navigation }: DefaultLayoutProps) =
581
588
  const { i18n } = useTranslation();
582
589
  const currentLanguage = i18n.language || 'en';
583
590
 
584
- const { startNav, endItems, navigationItems, mobileNavItems } = useMemo(() => {
591
+ const { startNav, endItems, navigationItems, mobileNavItems, hasRootNavItem } = useMemo(() => {
585
592
  const desktopNav = filterNavigationByViewport(navigation, 'desktop');
586
593
  const mobileNav = filterNavigationByViewport(navigation, 'mobile');
587
594
  const { start, end } = splitNavigationByPosition(desktopNav);
588
595
  const flat = flattenNavigationItems(desktopNav);
589
596
  const mobileFlat = flattenNavigationItems(mobileNav);
597
+ const hasRoot = flat.some((item) => item.path === '' || item.path === '/');
590
598
  return {
591
599
  startNav: filterNavigationForSidebar(start),
592
600
  endItems: end,
593
601
  navigationItems: flat,
594
602
  mobileNavItems: mobileFlat,
603
+ hasRootNavItem: hasRoot,
595
604
  };
596
605
  }, [navigation]);
597
606
 
@@ -637,10 +646,11 @@ const DefaultLayoutContent = ({ title, logo, navigation }: DefaultLayoutProps) =
637
646
  </main>
638
647
  </div>
639
648
 
640
- {/* Mobile bottom nav: visible only below md */}
649
+ {/* Mobile bottom nav: visible only below md; Home button only when no view for / */}
641
650
  <MobileBottomNav
642
651
  items={mobileNavItems}
643
652
  currentLanguage={currentLanguage}
653
+ showHomeButton={!hasRootNavItem}
644
654
  />
645
655
  </OverlayShell>
646
656
  </SidebarProvider>
@@ -9,7 +9,12 @@ import { Toaster } from '../../components/ui/sonner';
9
9
  import { ContentView } from '../../components/ContentView';
10
10
  import { useModal } from '../modal/ModalContext';
11
11
  import { useDrawer } from '../drawer/DrawerContext';
12
- import { getNavPathPrefix, resolveLocalizedString } from './utils';
12
+ import {
13
+ getNavPathPrefix,
14
+ getBaseUrlWithoutHash,
15
+ isHashRouterNavItem,
16
+ resolveLocalizedString,
17
+ } from './utils';
13
18
 
14
19
  interface OverlayShellProps {
15
20
  navigationItems: NavigationItem[];
@@ -43,8 +48,39 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
43
48
  const rawUrl = payload?.url;
44
49
  if (typeof rawUrl !== 'string' || !rawUrl.trim()) return;
45
50
 
51
+ closeModal();
52
+ closeDrawer();
53
+
46
54
  let pathname: string;
47
- if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) {
55
+
56
+ // Hash-based URL (e.g. http://localhost:5173/#/themes/foo): show non-hash path in shell, match by nav item base URL
57
+ if (rawUrl.includes('/#/')) {
58
+ try {
59
+ const parsed = new URL(rawUrl);
60
+ const hashPart = parsed.hash.slice(1); // strip leading #
61
+ const hashPath = hashPart.startsWith('/') ? hashPart : `/${hashPart}`;
62
+ const baseUrl = getBaseUrlWithoutHash(rawUrl);
63
+ const navItem = navigationItems.find(
64
+ (item) => isHashRouterNavItem(item) && getBaseUrlWithoutHash(item.url) === baseUrl,
65
+ );
66
+ if (!navItem) {
67
+ shellui.toast({
68
+ type: 'error',
69
+ title: t('navigationError') ?? 'Navigation error',
70
+ description:
71
+ t('navigationNotAllowed') ?? 'This URL is not configured in the app navigation.',
72
+ });
73
+ return;
74
+ }
75
+ const pathPrefix = getNavPathPrefix(navItem);
76
+ pathname =
77
+ hashPath === '/' || hashPath === ''
78
+ ? pathPrefix
79
+ : `${pathPrefix.replace(/\/$/, '')}${hashPath}`;
80
+ } catch {
81
+ pathname = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`;
82
+ }
83
+ } else if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) {
48
84
  try {
49
85
  pathname = new URL(rawUrl).pathname;
50
86
  } catch {
@@ -54,9 +90,6 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
54
90
  pathname = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`;
55
91
  }
56
92
 
57
- closeModal();
58
- closeDrawer();
59
-
60
93
  const isHomepage = pathname === '/' || pathname === '';
61
94
  const isAllowed =
62
95
  isHomepage ||
@@ -5,6 +5,34 @@ export function getNavPathPrefix(item: NavigationItem): string {
5
5
  return item.path === '/' || item.path === '' ? '/' : `/${item.path}`;
6
6
  }
7
7
 
8
+ /** Whether a URL string uses hash-based routing (e.g. contains /#/). */
9
+ export function isHashRouterUrl(url: string): boolean {
10
+ return url.includes('/#/');
11
+ }
12
+
13
+ /** Whether a nav item uses hash-based routing (explicit flag or inferred from url). */
14
+ export function isHashRouterNavItem(item: NavigationItem): boolean {
15
+ if (item.useHashRouter === true) return true;
16
+ if (item.useHashRouter === false) return false;
17
+ return isHashRouterUrl(item.url);
18
+ }
19
+
20
+ /** Base URL without hash (origin + pathname before #). Used to match and build iframe URLs for hash apps. */
21
+ export function getBaseUrlWithoutHash(url: string): string {
22
+ const hashIndex = url.indexOf('#');
23
+ if (hashIndex === -1) return url;
24
+ const base = url.slice(0, hashIndex);
25
+ return base.endsWith('/') ? base : `${base}/`;
26
+ }
27
+
28
+ /** Hash path from a URL (part after #), e.g. "/themes" from "http://localhost:5173/#/themes". Returns "" if no hash. */
29
+ export function getHashPathFromUrl(url: string): string {
30
+ const hashIndex = url.indexOf('#');
31
+ if (hashIndex === -1) return '';
32
+ const hash = url.slice(hashIndex + 1);
33
+ return hash.startsWith('/') ? hash : `/${hash}`;
34
+ }
35
+
8
36
  /** 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
37
  export function getActivePathPrefix(pathname: string, items: NavigationItem[]): string | null {
10
38
  const linkItems = items.filter(
@@ -184,7 +184,7 @@ export const SettingsView = () => {
184
184
  // Navigate back to settings root
185
185
  const handleBackToSettings = useCallback(() => {
186
186
  // Navigate to settings root, replacing current history entry
187
- navigate(urls.settings, { replace: true });
187
+ navigate(urls.settings);
188
188
  }, [navigate]);
189
189
 
190
190
  return (
@@ -109,8 +109,18 @@ export const createRoutes = (config: ShellUIConfig): RouteObject[] => {
109
109
  ),
110
110
  });
111
111
  });
112
+ // Catch-all: no nav match (e.g. /layout) → ViewRoute can use root item with pathname as hash subpath to avoid 404
113
+ (layoutRoute.children as RouteObject[]).push({
114
+ path: '*',
115
+ element: (
116
+ <Suspense fallback={<RouteFallback />}>
117
+ <ViewRoute navigation={navigationItems} />
118
+ </Suspense>
119
+ ),
120
+ });
112
121
  }
113
- (routes[0].children as RouteObject[]).push(layoutRoute);
122
+ // Layout must be before the catch-all (*) so paths like /layout are handled by layout → ViewRoute (root fallback), not 404
123
+ (routes[0].children as RouteObject[]).unshift(layoutRoute);
114
124
 
115
125
  return routes;
116
126
  };