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

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.0",
3
+ "version": "0.2.0-alpha.2",
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.0"
61
+ "@shellui/sdk": "0.2.0-alpha.2"
62
62
  },
63
63
  "peerDependencies": {
64
64
  "react": "^18.0.0 || ^19.0.0",
@@ -16,22 +16,17 @@ const logger = getLogger('shellcore');
16
16
 
17
17
  interface ContentViewProps {
18
18
  url: string;
19
- /** Base URL for this content (effective nav item URL). Used for URL sync; when omitted, falls back to navItem.url or url. */
20
- baseUrl?: string;
21
19
  pathPrefix: string;
22
20
  ignoreMessages?: boolean;
23
- /** Nav item for this content; may be undefined when opening by URL (e.g. settings modal) if no matching item found. */
24
- navItem?: NavigationItem | null;
21
+ navItem: NavigationItem;
25
22
  }
26
23
 
27
24
  export const ContentView = ({
28
25
  url,
29
- baseUrl: baseUrlProp,
30
26
  pathPrefix,
31
27
  ignoreMessages = false,
32
28
  navItem,
33
29
  }: ContentViewProps) => {
34
- const baseUrl = baseUrlProp ?? navItem?.url ?? url;
35
30
  const navigate = useNavigate();
36
31
  const iframeRef = useRef<HTMLIFrameElement>(null);
37
32
  const isInternalNavigation = useRef(false);
@@ -63,13 +58,9 @@ export const ContentView = ({
63
58
  }
64
59
 
65
60
  const { pathname, search, hash } = data.payload as ShellUIUrlPayload;
66
- // Use pathname part of baseUrl for comparison (iframe pathname is always path-only)
67
- const basePathname =
68
- baseUrl.startsWith('http') || baseUrl.startsWith('//')
69
- ? new URL(baseUrl, window.location.origin).pathname
70
- : baseUrl;
71
- let cleanPathname = pathname.startsWith(basePathname)
72
- ? pathname.slice(basePathname.length)
61
+ // Remove leading slash and trailing slashes from iframe pathname
62
+ let cleanPathname = pathname.startsWith(navItem.url)
63
+ ? pathname.slice(navItem.url.length)
73
64
  : pathname;
74
65
  cleanPathname = cleanPathname.startsWith('/') ? cleanPathname.slice(1) : cleanPathname;
75
66
  cleanPathname = cleanPathname.replace(/\/+$/, ''); // Remove trailing slashes
@@ -111,7 +102,7 @@ export const ContentView = ({
111
102
  return () => {
112
103
  cleanup();
113
104
  };
114
- }, [pathPrefix, navigate, baseUrl]);
105
+ }, [pathPrefix, navigate]);
115
106
 
116
107
  // Hide loading overlay when iframe sends SHELLUI_INITIALIZED
117
108
  useEffect(() => {
@@ -1,6 +1,6 @@
1
1
  import { useMemo } from 'react';
2
2
  import { Navigate, useLocation } from 'react-router';
3
- import { getNavPathPrefix, getEffectiveUrl } from '../features/layouts/utils';
3
+ import { getNavPathPrefix } from '../features/layouts/utils';
4
4
  import { ContentView } from './ContentView';
5
5
  import type { NavigationItem } from '../features/config/types';
6
6
 
@@ -32,18 +32,15 @@ export const ViewRoute = ({ navigation }: ViewRouteProps) => {
32
32
  const pathPrefix = getNavPathPrefix(navItem);
33
33
  const subPath = pathname.length > pathPrefix.length ? pathname.slice(pathPrefix.length + 1) : '';
34
34
 
35
- // Effective URL: url string or app-path URL when item uses a component
36
- const baseUrl = getEffectiveUrl(navItem);
37
- let finalUrl = baseUrl;
38
- if (!navItem.component && subPath && navItem.url) {
39
- const base = navItem.url.endsWith('/') ? navItem.url : `${navItem.url}/`;
40
- finalUrl = `${base}${subPath}`;
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}`;
41
40
  }
42
-
43
41
  return (
44
42
  <ContentView
45
43
  url={finalUrl}
46
- baseUrl={baseUrl}
47
44
  pathPrefix={navItem.path}
48
45
  navItem={navItem}
49
46
  />
@@ -11,7 +11,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
11
11
  return (
12
12
  <Sonner
13
13
  position="top-center"
14
- theme={settings.appearance.theme as 'light' | 'dark' | 'system'}
14
+ theme={settings.appearance.colorScheme as 'light' | 'dark' | 'system'}
15
15
  className="toaster group"
16
16
  style={{
17
17
  zIndex: Z_INDEX.TOAST,
@@ -1,6 +1,4 @@
1
1
  export default {
2
2
  settings: '/__settings',
3
3
  cookiePreferences: '/__cookie-preferences',
4
- /** Base path for navigation items that use a React component; full URL is /__app/{path}. */
5
- appPath: '/__app',
6
4
  };
@@ -1,15 +1,12 @@
1
1
  import { createContext, useState, createElement, type ReactNode } from 'react';
2
- import type { ComponentType } from 'react';
3
2
  import { getLogger } from '@shellui/sdk';
4
3
  import type { ShellUIConfig } from './types';
5
- import shelluiConfig, { shelluiComponents } from '@shellui/config';
4
+ import shelluiConfig from '@shellui/config';
6
5
 
7
6
  const logger = getLogger('shellcore');
8
7
 
9
8
  export interface ConfigContextValue {
10
9
  config: ShellUIConfig;
11
- /** Map of nav path -> component for /__app/:path (injected when componentPath is set in config). */
12
- shelluiComponents: Record<string, ComponentType>;
13
10
  }
14
11
 
15
12
  export const ConfigContext = createContext<ConfigContextValue | null>(null);
@@ -47,9 +44,7 @@ export function ConfigProvider(props: ConfigProviderProps): ReturnType<typeof cr
47
44
  }
48
45
  });
49
46
 
50
- const value: ConfigContextValue = {
51
- config,
52
- shelluiComponents: typeof shelluiComponents !== 'undefined' ? shelluiComponents : {},
53
- };
47
+ // Always provide a value - never null - to prevent "useConfig must be used within ConfigProvider" errors
48
+ const value: ConfigContextValue = { config };
54
49
  return createElement(ConfigContext.Provider, { value }, props.children);
55
50
  }
@@ -1,13 +1,9 @@
1
1
  /**
2
2
  * Type declaration for the virtual @shellui/config module.
3
3
  * The CLI injects this module at build time via Vite (virtual:shellui-config).
4
- * When nav items have componentPath, the CLI also injects shelluiComponents for /__app/:path routes.
5
4
  */
6
5
  declare module '@shellui/config' {
7
- import type { ComponentType } from 'react';
8
6
  import type { ShellUIConfig } from './types';
9
7
  export const shelluiConfig: ShellUIConfig;
10
- /** Map of nav path -> component for component-based items (injected when componentPath is set). */
11
- export const shelluiComponents: Record<string, ComponentType>;
12
8
  export default shelluiConfig;
13
9
  }
@@ -1,5 +1,3 @@
1
- import type { ComponentType } from 'react';
2
-
3
1
  // Language-specific label/title
4
2
  export type LocalizedString =
5
3
  | string
@@ -18,12 +16,7 @@ export type LayoutType = 'sidebar' | 'fullscreen' | 'windows' | 'app-bar';
18
16
  export interface NavigationItem {
19
17
  label: string | LocalizedString;
20
18
  path: string;
21
- /** URL to load in the content area (iframe). Omit when using component; then the app-path URL is used. */
22
- url?: string;
23
- /** React component to render for this item. When set, a URL under the app-path is generated so the content can be loaded in an iframe like URL-based items. The CLI infers the component module path from your config file imports so you don't need to set componentPath. */
24
- component?: ComponentType;
25
- /** @internal Set by the CLI when loading config (inferred from component + config file imports). Only needed if you bypass the CLI. */
26
- componentPath?: string;
19
+ url: string;
27
20
  icon?: string; // Path to SVG icon file (e.g., '/icons/book-open.svg')
28
21
  /** When true, hide this item from the sidebar and 404 page; route remains valid and item still appears in Develop settings. */
29
22
  hidden?: boolean;
@@ -6,7 +6,7 @@ import type { NavigationItem, NavigationGroup } from '../config/types';
6
6
  import {
7
7
  filterNavigationByViewport,
8
8
  flattenNavigationItems,
9
- getEffectiveUrl,
9
+ getActivePathPrefix,
10
10
  getNavPathPrefix,
11
11
  resolveLocalizedString as resolveNavLabel,
12
12
  splitNavigationByPosition,
@@ -49,17 +49,22 @@ function resolveLocalizedLabel(
49
49
  }
50
50
 
51
51
  /** End link: icon-only or first-letter badge with themed tooltip. */
52
- function TopBarEndItem({ item, label }: { item: NavigationItem; label: string }) {
52
+ function TopBarEndItem({
53
+ item,
54
+ label,
55
+ activePathPrefix,
56
+ }: {
57
+ item: NavigationItem;
58
+ label: string;
59
+ activePathPrefix: string | null;
60
+ }) {
53
61
  const pathPrefix = getNavPathPrefix(item);
54
62
  const isOverlay = item.openIn === 'modal' || item.openIn === 'drawer';
55
63
  const isExternal = item.openIn === 'external';
56
- const location = useLocation();
57
64
  const isActive =
58
- !isOverlay &&
59
- !isExternal &&
60
- (location.pathname === pathPrefix || location.pathname.startsWith(`${pathPrefix}/`));
65
+ !isOverlay && !isExternal && pathPrefix === activePathPrefix;
61
66
 
62
- const faviconUrl = isExternal && !item.icon ? getExternalFaviconUrl(getEffectiveUrl(item)) : null;
67
+ const faviconUrl = isExternal && !item.icon ? getExternalFaviconUrl(item.url) : null;
63
68
  const iconSrc = item.icon ?? faviconUrl ?? null;
64
69
  const firstLetter = label ? label.charAt(0).toUpperCase() : '?';
65
70
 
@@ -94,7 +99,7 @@ function TopBarEndItem({ item, label }: { item: NavigationItem; label: string })
94
99
  return wrap(
95
100
  <button
96
101
  type="button"
97
- onClick={() => shellui.openModal(getEffectiveUrl(item))}
102
+ onClick={() => shellui.openModal(item.url)}
98
103
  className={buttonClass}
99
104
  aria-label={label}
100
105
  >
@@ -106,7 +111,7 @@ function TopBarEndItem({ item, label }: { item: NavigationItem; label: string })
106
111
  return wrap(
107
112
  <button
108
113
  type="button"
109
- onClick={() => shellui.openDrawer({ url: getEffectiveUrl(item), position: item.drawerPosition })}
114
+ onClick={() => shellui.openDrawer({ url: item.url, position: item.drawerPosition })}
110
115
  className={buttonClass}
111
116
  aria-label={label}
112
117
  >
@@ -117,7 +122,7 @@ function TopBarEndItem({ item, label }: { item: NavigationItem; label: string })
117
122
  if (item.openIn === 'external') {
118
123
  return wrap(
119
124
  <a
120
- href={getEffectiveUrl(item)}
125
+ href={item.url}
121
126
  target="_blank"
122
127
  rel="noopener noreferrer"
123
128
  className={buttonClass}
@@ -144,16 +149,18 @@ export function AppBarLayout({ title, logo, navigation }: AppBarLayoutProps) {
144
149
  const navigate = useNavigate();
145
150
  const currentLanguage = i18n.language || 'en';
146
151
 
147
- const { endNavItems, navigationItems, displayStartItems } = useMemo(() => {
152
+ const { endNavItems, navigationItems, displayStartItems, activePathPrefix } = useMemo(() => {
148
153
  const desktopNav = filterNavigationByViewport(navigation, 'desktop');
149
154
  const { start, end } = splitNavigationByPosition(desktopNav);
150
155
  const startItems = flattenNavigationItems(start).filter((i) => !i.hidden);
156
+ const flat = flattenNavigationItems(navigation);
151
157
  return {
152
158
  endNavItems: flattenNavigationItems(end).filter((i) => !i.hidden),
153
- navigationItems: flattenNavigationItems(navigation),
159
+ navigationItems: flat,
154
160
  displayStartItems: withHomepageWhenNoRoot(startItems),
161
+ activePathPrefix: getActivePathPrefix(location.pathname, flat),
155
162
  };
156
- }, [navigation]);
163
+ }, [navigation, location.pathname]);
157
164
 
158
165
  useEffect(() => {
159
166
  if (!title) return;
@@ -243,6 +250,7 @@ export function AppBarLayout({ title, logo, navigation }: AppBarLayoutProps) {
243
250
  key={item.path}
244
251
  item={item}
245
252
  label={resolveNavLabel(item.label, currentLanguage) || item.path || ''}
253
+ activePathPrefix={activePathPrefix}
246
254
  />
247
255
  ))}
248
256
  </div>
@@ -22,7 +22,7 @@ import {
22
22
  filterNavigationByViewport,
23
23
  filterNavigationForSidebar,
24
24
  flattenNavigationItems,
25
- getEffectiveUrl,
25
+ getActivePathPrefix,
26
26
  getNavPathPrefix,
27
27
  HOMEPAGE_NAV_ITEM,
28
28
  resolveLocalizedString as resolveNavLabel,
@@ -83,6 +83,12 @@ const NavigationContent = ({
83
83
  });
84
84
  }, [navigation]);
85
85
 
86
+ const flatItems = useMemo(() => flattenNavigationItems(navigation), [navigation]);
87
+ const activePathPrefix = useMemo(
88
+ () => getActivePathPrefix(location.pathname, flatItems),
89
+ [location.pathname, flatItems],
90
+ );
91
+
86
92
  // Helper to check if an item is a group
87
93
  const isGroup = (item: NavigationItem | NavigationGroup): item is NavigationGroup => {
88
94
  return 'title' in item && 'items' in item;
@@ -94,11 +100,9 @@ const NavigationContent = ({
94
100
  const isOverlay = navItem.openIn === 'modal' || navItem.openIn === 'drawer';
95
101
  const isExternal = navItem.openIn === 'external';
96
102
  const isActive =
97
- !isOverlay &&
98
- !isExternal &&
99
- (location.pathname === pathPrefix || location.pathname.startsWith(`${pathPrefix}/`));
103
+ !isOverlay && !isExternal && pathPrefix === activePathPrefix;
100
104
  const itemLabel = resolveLocalizedString(navItem.label, currentLanguage);
101
- const faviconUrl = isExternal && !navItem.icon ? getExternalFaviconUrl(getEffectiveUrl(navItem)) : null;
105
+ const faviconUrl = isExternal && !navItem.icon ? getExternalFaviconUrl(navItem.url) : null;
102
106
  const iconSrc = navItem.icon ?? faviconUrl ?? null;
103
107
  const iconEl = iconSrc ? (
104
108
  <img
@@ -123,7 +127,7 @@ const NavigationContent = ({
123
127
  navItem.openIn === 'modal' ? (
124
128
  <button
125
129
  type="button"
126
- onClick={() => shellui.openModal(getEffectiveUrl(navItem))}
130
+ onClick={() => shellui.openModal(navItem.url)}
127
131
  className="flex items-center gap-2 w-full cursor-pointer text-left"
128
132
  >
129
133
  {content}
@@ -131,14 +135,14 @@ const NavigationContent = ({
131
135
  ) : navItem.openIn === 'drawer' ? (
132
136
  <button
133
137
  type="button"
134
- onClick={() => shellui.openDrawer({ url: getEffectiveUrl(navItem), position: navItem.drawerPosition })}
138
+ onClick={() => shellui.openDrawer({ url: navItem.url, position: navItem.drawerPosition })}
135
139
  className="flex items-center gap-2 w-full cursor-pointer text-left"
136
140
  >
137
141
  {content}
138
142
  </button>
139
143
  ) : navItem.openIn === 'external' ? (
140
144
  <a
141
- href={getEffectiveUrl(navItem)}
145
+ href={navItem.url}
142
146
  target="_blank"
143
147
  rel="noopener noreferrer"
144
148
  className="flex items-center gap-2 w-full"
@@ -306,7 +310,7 @@ const BottomNavItem = ({
306
310
  return (
307
311
  <button
308
312
  type="button"
309
- onClick={() => shellui.openModal(getEffectiveUrl(item))}
313
+ onClick={() => shellui.openModal(item.url)}
310
314
  className={baseClass}
311
315
  >
312
316
  {content}
@@ -317,7 +321,7 @@ const BottomNavItem = ({
317
321
  return (
318
322
  <button
319
323
  type="button"
320
- onClick={() => shellui.openDrawer({ url: getEffectiveUrl(item), position: item.drawerPosition })}
324
+ onClick={() => shellui.openDrawer({ url: item.url, position: item.drawerPosition })}
321
325
  className={baseClass}
322
326
  >
323
327
  {content}
@@ -327,7 +331,7 @@ const BottomNavItem = ({
327
331
  if (item.openIn === 'external') {
328
332
  return (
329
333
  <a
330
- href={getEffectiveUrl(item)}
334
+ href={item.url}
331
335
  target="_blank"
332
336
  rel="noopener noreferrer"
333
337
  className={baseClass}
@@ -443,6 +447,11 @@ const MobileBottomNav = ({
443
447
  const navRef = useRef<HTMLElement>(null);
444
448
  const [rowWidth, setRowWidth] = useState(0);
445
449
 
450
+ const activePathPrefix = useMemo(
451
+ () => getActivePathPrefix(location.pathname, items),
452
+ [location.pathname, items],
453
+ );
454
+
446
455
  useLayoutEffect(() => {
447
456
  const el = navRef.current;
448
457
  if (!el) return;
@@ -483,17 +492,15 @@ const MobileBottomNav = ({
483
492
  const pathPrefix = getNavPathPrefix(item);
484
493
  const isOverlayOrExternal =
485
494
  item.openIn === 'modal' || item.openIn === 'drawer' || item.openIn === 'external';
486
- const isActive =
487
- !isOverlayOrExternal &&
488
- (location.pathname === pathPrefix || location.pathname.startsWith(`${pathPrefix}/`));
495
+ const isActive = !isOverlayOrExternal && pathPrefix === activePathPrefix;
489
496
  const label = resolveNavLabel(item.label, currentLanguage);
490
497
  const faviconUrl =
491
- item.openIn === 'external' && !item.icon ? getExternalFaviconUrl(getEffectiveUrl(item)) : null;
498
+ item.openIn === 'external' && !item.icon ? getExternalFaviconUrl(item.url) : null;
492
499
  const iconSrc = item.icon ?? faviconUrl ?? null;
493
500
  const applyIconTheme = iconSrc ? isAppIcon(iconSrc) : false;
494
501
  return (
495
502
  <BottomNavItem
496
- key={`${item.path}-${getEffectiveUrl(item)}-${index}`}
503
+ key={`${item.path}-${item.url}-${index}`}
497
504
  item={item}
498
505
  label={label}
499
506
  isActive={isActive}
@@ -9,7 +9,7 @@ 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 { getEffectiveUrl, getNavPathPrefix, normalizeUrlToPathname, resolveLocalizedString } from './utils';
12
+ import { getNavPathPrefix, resolveLocalizedString } from './utils';
13
13
 
14
14
  interface OverlayShellProps {
15
15
  navigationItems: NavigationItem[];
@@ -90,9 +90,7 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
90
90
  <>
91
91
  <DialogTitle className="sr-only">
92
92
  {resolveLocalizedString(
93
- navigationItems.find(
94
- (item) => normalizeUrlToPathname(getEffectiveUrl(item)) === normalizeUrlToPathname(modalUrl),
95
- )?.label,
93
+ navigationItems.find((item) => item.url === modalUrl)?.label,
96
94
  currentLanguage,
97
95
  )}
98
96
  </DialogTitle>
@@ -107,11 +105,7 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
107
105
  url={modalUrl}
108
106
  pathPrefix="settings"
109
107
  ignoreMessages={true}
110
- navItem={
111
- navigationItems.find(
112
- (item) => normalizeUrlToPathname(getEffectiveUrl(item)) === normalizeUrlToPathname(modalUrl),
113
- ) ?? undefined
114
- }
108
+ navItem={navigationItems.find((item) => item.url === modalUrl) as NavigationItem}
115
109
  />
116
110
  </div>
117
111
  </>
@@ -153,11 +147,7 @@ export function OverlayShell({ navigationItems, children }: OverlayShellProps) {
153
147
  url={drawerUrl}
154
148
  pathPrefix="settings"
155
149
  ignoreMessages={true}
156
- navItem={
157
- navigationItems.find(
158
- (item) => normalizeUrlToPathname(getEffectiveUrl(item)) === normalizeUrlToPathname(drawerUrl),
159
- ) ?? undefined
160
- }
150
+ navItem={navigationItems.find((item) => item.url === drawerUrl) as NavigationItem}
161
151
  />
162
152
  </div>
163
153
  ) : (
@@ -11,7 +11,6 @@ import { shellui } from '@shellui/sdk';
11
11
  import type { NavigationItem, NavigationGroup } from '../config/types';
12
12
  import {
13
13
  flattenNavigationItems,
14
- getEffectiveUrl,
15
14
  getNavPathPrefix,
16
15
  resolveLocalizedString as resolveNavLabel,
17
16
  splitNavigationByPosition,
@@ -542,7 +541,7 @@ export function WindowsLayout({
542
541
  const label =
543
542
  typeof item.label === 'string' ? item.label : resolveNavLabel(item.label, currentLanguage);
544
543
  const faviconUrl =
545
- item.openIn === 'external' && !item.icon ? getExternalFaviconUrl(getEffectiveUrl(item)) : null;
544
+ item.openIn === 'external' && !item.icon ? getExternalFaviconUrl(item.url) : null;
546
545
  const icon = item.icon ?? faviconUrl ?? null;
547
546
  const id = genId();
548
547
  const bounds = {
@@ -557,7 +556,7 @@ export function WindowsLayout({
557
556
  id,
558
557
  path: item.path,
559
558
  pathname: getNavPathPrefix(item),
560
- baseUrl: getEffectiveUrl(item),
559
+ baseUrl: item.url,
561
560
  label,
562
561
  icon,
563
562
  bounds,
@@ -610,17 +609,17 @@ export function WindowsLayout({
610
609
  const handleNavClick = useCallback(
611
610
  (item: NavigationItem) => {
612
611
  if (item.openIn === 'modal') {
613
- shellui.openModal(getEffectiveUrl(item));
612
+ shellui.openModal(item.url);
614
613
  setStartMenuOpen(false);
615
614
  return;
616
615
  }
617
616
  if (item.openIn === 'drawer') {
618
- shellui.openDrawer({ url: getEffectiveUrl(item), position: item.drawerPosition });
617
+ shellui.openDrawer({ url: item.url, position: item.drawerPosition });
619
618
  setStartMenuOpen(false);
620
619
  return;
621
620
  }
622
621
  if (item.openIn === 'external') {
623
- window.open(getEffectiveUrl(item), '_blank', 'noopener,noreferrer');
622
+ window.open(item.url, '_blank', 'noopener,noreferrer');
624
623
  setStartMenuOpen(false);
625
624
  return;
626
625
  }
@@ -710,7 +709,7 @@ export function WindowsLayout({
710
709
  : resolveNavLabel(item.label, currentLanguage);
711
710
  const icon =
712
711
  item.icon ??
713
- (item.openIn === 'external' ? getExternalFaviconUrl(getEffectiveUrl(item)) : null);
712
+ (item.openIn === 'external' ? getExternalFaviconUrl(item.url) : null);
714
713
  return (
715
714
  <button
716
715
  key={item.path}
@@ -792,7 +791,7 @@ export function WindowsLayout({
792
791
  : resolveNavLabel(item.label, currentLanguage);
793
792
  const icon =
794
793
  item.icon ??
795
- (item.openIn === 'external' ? getExternalFaviconUrl(getEffectiveUrl(item)) : null);
794
+ (item.openIn === 'external' ? getExternalFaviconUrl(item.url) : null);
796
795
  return (
797
796
  <button
798
797
  key={item.path}
@@ -1,33 +1,25 @@
1
1
  import type { NavigationItem, NavigationGroup, LocalizedString } from '../config/types';
2
- import urls from '../../constants/urls';
3
2
 
4
3
  /** Path prefix for a nav item: "/" for root (path '' or '/'), otherwise "/{path}". */
5
4
  export function getNavPathPrefix(item: NavigationItem): string {
6
5
  return item.path === '/' || item.path === '' ? '/' : `/${item.path}`;
7
6
  }
8
7
 
9
- /** Effective URL for a nav item: url if set, otherwise app-path URL for component-based items. */
10
- export function getEffectiveUrl(item: NavigationItem): string {
11
- if (item.url != null && item.url !== '') {
12
- return item.url;
13
- }
14
- const base = typeof window !== 'undefined' ? window.location.origin : '';
15
- const path = item.path === '/' || item.path === '' ? 'home' : item.path;
16
- return `${base}${urls.appPath}/${path}`;
17
- }
18
-
19
- /** Normalize a URL to pathname for comparison (handles full URLs and path-only). */
20
- export function normalizeUrlToPathname(url: string): string {
21
- if (!url || typeof url !== 'string') return '';
22
- const s = url.trim();
23
- if (s.startsWith('http://') || s.startsWith('https://') || s.startsWith('//')) {
24
- try {
25
- return new URL(s, 'http://localhost').pathname.replace(/\/+$/, '') || '/';
26
- } catch {
27
- return s.startsWith('/') ? s.replace(/\/+$/, '') || '/' : `/${s}`.replace(/\/+$/, '') || '/';
28
- }
29
- }
30
- return (s.startsWith('/') ? s : `/${s}`).replace(/\/+$/, '') || '/';
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 {
13
+ const linkItems = items.filter(
14
+ (i) => i.openIn !== 'modal' && i.openIn !== 'drawer' && i.openIn !== 'external',
15
+ );
16
+ const matching = linkItems
17
+ .map((i) => getNavPathPrefix(i))
18
+ .filter(
19
+ (p) => pathname === p || pathname.startsWith(p === '/' ? '/' : p + '/'),
20
+ );
21
+ if (matching.length === 0) return null;
22
+ return matching.reduce((a, b) => (a.length >= b.length ? a : b));
31
23
  }
32
24
 
33
25
  /** Resolve a localized string to a single string for the given language. */
@@ -5,12 +5,13 @@ import {
5
5
  type ShellUIMessage,
6
6
  type Settings,
7
7
  type SettingsNavigationItem,
8
+ type Appearance,
8
9
  } from '@shellui/sdk';
9
10
  import { SettingsContext } from './SettingsContext';
10
11
  import { useConfig } from '../config/useConfig';
11
12
  import { useTranslation } from 'react-i18next';
12
- import type { NavigationItem, NavigationGroup } from '../config/types';
13
- import { getEffectiveUrl } from '../layouts/utils';
13
+ import type { NavigationItem, NavigationGroup, ShellUIConfig } from '../config/types';
14
+ import { getTheme, registerTheme } from '../theme/themes';
14
15
 
15
16
  const logger = getLogger('shellcore');
16
17
 
@@ -32,18 +33,77 @@ function resolveLabel(
32
33
  return value[lang] || value.en || value.fr || Object.values(value)[0] || '';
33
34
  }
34
35
 
35
- function buildSettingsWithNavigation(
36
+ function resolveColorMode(colorScheme: 'light' | 'dark' | 'system'): 'light' | 'dark' {
37
+ if (colorScheme === 'system' && typeof window !== 'undefined') {
38
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
39
+ }
40
+ return colorScheme === 'dark' ? 'dark' : 'light';
41
+ }
42
+
43
+ /**
44
+ * Build the full appearance object for settings propagation so apps receive all theme
45
+ * variable values and can style without knowing the theme name.
46
+ */
47
+ function getResolvedAppearanceForSettings(
48
+ settings: Settings,
49
+ config: ShellUIConfig | undefined,
50
+ ): Appearance | undefined {
51
+ if (typeof window === 'undefined') return undefined;
52
+ config?.themes?.forEach(registerTheme);
53
+ const themeName =
54
+ settings.appearance?.name || config?.defaultTheme || 'default';
55
+ const themeDef = getTheme(themeName) || getTheme('default');
56
+ if (!themeDef) return undefined;
57
+ const colorScheme = settings.appearance?.colorScheme ?? 'system';
58
+ const mode = resolveColorMode(colorScheme);
59
+ return {
60
+ name: themeDef.name,
61
+ displayName: themeDef.displayName,
62
+ mode,
63
+ colorScheme,
64
+ colors: themeDef.colors,
65
+ ...(themeDef.fontFamily !== undefined && { fontFamily: themeDef.fontFamily }),
66
+ ...(themeDef.bodyFontFamily !== undefined && {
67
+ bodyFontFamily: themeDef.bodyFontFamily,
68
+ }),
69
+ ...(themeDef.headingFontFamily !== undefined && {
70
+ headingFontFamily: themeDef.headingFontFamily,
71
+ }),
72
+ ...(themeDef.letterSpacing !== undefined && {
73
+ letterSpacing: themeDef.letterSpacing,
74
+ }),
75
+ ...(themeDef.textShadow !== undefined && { textShadow: themeDef.textShadow }),
76
+ ...(themeDef.lineHeight !== undefined && { lineHeight: themeDef.lineHeight }),
77
+ ...(themeDef.fontFiles !== undefined &&
78
+ themeDef.fontFiles.length > 0 && { fontFiles: themeDef.fontFiles }),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Build settings for propagation to iframes: inject navigation and full theme object
84
+ * so apps receive all theme variable values.
85
+ */
86
+ function buildSettingsForPropagation(
36
87
  settings: Settings,
37
- navigation: (NavigationItem | NavigationGroup)[] | undefined,
88
+ config: ShellUIConfig | undefined,
38
89
  lang: string,
39
90
  ): Settings {
40
- if (!navigation?.length) return settings;
41
- const items: SettingsNavigationItem[] = flattenNavigationItems(navigation).map((item) => ({
42
- path: item.path,
43
- url: getEffectiveUrl(item),
44
- label: resolveLabel(item.label, lang),
45
- }));
46
- return { ...settings, navigation: { items } };
91
+ const appearance = getResolvedAppearanceForSettings(settings, config);
92
+ let result: Settings = {
93
+ ...settings,
94
+ appearance: appearance ?? settings.appearance,
95
+ };
96
+ 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
+ }));
104
+ result = { ...result, navigation: { items } };
105
+ }
106
+ return result;
47
107
  }
48
108
 
49
109
  const STORAGE_KEY = 'shellui:settings';
@@ -56,6 +116,75 @@ const getBrowserTimezone = (): string => {
56
116
  return 'UTC';
57
117
  };
58
118
 
119
+ const defaultAppearance: Appearance = {
120
+ name: 'default',
121
+ displayName: 'Default',
122
+ mode: 'light',
123
+ colorScheme: 'system',
124
+ colors: {
125
+ light: {
126
+ background: '#ffffff',
127
+ foreground: '#09090b',
128
+ card: '#ffffff',
129
+ cardForeground: '#09090b',
130
+ popover: '#ffffff',
131
+ popoverForeground: '#09090b',
132
+ primary: '#18181b',
133
+ primaryForeground: '#fafafa',
134
+ secondary: '#f4f4f5',
135
+ secondaryForeground: '#18181b',
136
+ muted: '#f4f4f5',
137
+ mutedForeground: '#71717a',
138
+ accent: '#f4f4f5',
139
+ accentForeground: '#18181b',
140
+ destructive: '#ef4444',
141
+ destructiveForeground: '#fafafa',
142
+ border: '#e4e4e7',
143
+ input: '#e4e4e7',
144
+ ring: '#18181b',
145
+ radius: '0.5rem',
146
+ sidebarBackground: '#fafafa',
147
+ sidebarForeground: '#09090b',
148
+ sidebarPrimary: '#18181b',
149
+ sidebarPrimaryForeground: '#fafafa',
150
+ sidebarAccent: '#e4e4e7',
151
+ sidebarAccentForeground: '#18181b',
152
+ sidebarBorder: '#e4e4e7',
153
+ sidebarRing: '#18181b',
154
+ },
155
+ dark: {
156
+ background: '#09090b',
157
+ foreground: '#fafafa',
158
+ card: '#09090b',
159
+ cardForeground: '#fafafa',
160
+ popover: '#09090b',
161
+ popoverForeground: '#fafafa',
162
+ primary: '#fafafa',
163
+ primaryForeground: '#18181b',
164
+ secondary: '#27272a',
165
+ secondaryForeground: '#fafafa',
166
+ muted: '#27272a',
167
+ mutedForeground: '#a1a1aa',
168
+ accent: '#27272a',
169
+ accentForeground: '#fafafa',
170
+ destructive: '#7f1d1d',
171
+ destructiveForeground: '#fafafa',
172
+ border: '#27272a',
173
+ input: '#27272a',
174
+ ring: '#d4d4d8',
175
+ radius: '0.5rem',
176
+ sidebarBackground: '#09090b',
177
+ sidebarForeground: '#fafafa',
178
+ sidebarPrimary: '#fafafa',
179
+ sidebarPrimaryForeground: '#18181b',
180
+ sidebarAccent: '#27272a',
181
+ sidebarAccentForeground: '#fafafa',
182
+ sidebarBorder: '#27272a',
183
+ sidebarRing: '#d4d4d8',
184
+ },
185
+ },
186
+ };
187
+
59
188
  const defaultSettings: Settings = {
60
189
  developerFeatures: {
61
190
  enabled: false,
@@ -69,10 +198,7 @@ const defaultSettings: Settings = {
69
198
  shellcore: false,
70
199
  },
71
200
  },
72
- appearance: {
73
- theme: 'system',
74
- themeName: 'default',
75
- },
201
+ appearance: defaultAppearance,
76
202
  language: {
77
203
  code: 'en',
78
204
  },
@@ -122,8 +248,13 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
122
248
  },
123
249
  },
124
250
  appearance: {
125
- theme: parsed.appearance?.theme || defaultSettings.appearance.theme,
126
- themeName: parsed.appearance?.themeName || defaultSettings.appearance.themeName,
251
+ ...defaultAppearance,
252
+ ...parsed.appearance,
253
+ // Migrate from legacy theme/themeName
254
+ name: parsed.appearance?.name ?? parsed.appearance?.themeName ?? defaultAppearance.name,
255
+ colorScheme:
256
+ parsed.appearance?.colorScheme ?? parsed.appearance?.theme ?? defaultAppearance.colorScheme,
257
+ colors: parsed.appearance?.colors ?? defaultAppearance.colors,
127
258
  },
128
259
  language: {
129
260
  code: parsed.language?.code || defaultSettings.language.code,
@@ -179,9 +310,9 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
179
310
  try {
180
311
  localStorage.setItem(STORAGE_KEY, JSON.stringify(newSettings));
181
312
  // Confirm: root updated localStorage; re-inject navigation when propagating
182
- const settingsToPropagate = buildSettingsWithNavigation(
313
+ const settingsToPropagate = buildSettingsForPropagation(
183
314
  newSettings,
184
- config?.navigation,
315
+ config,
185
316
  i18n.language || 'en',
186
317
  );
187
318
  logger.info('Root Parent received settings update', { message });
@@ -202,9 +333,9 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
202
333
  () => {
203
334
  // Use ref to always get current settings (avoids stale closure)
204
335
  const currentSettings = settingsRef.current ?? defaultSettings;
205
- const settingsWithNav = buildSettingsWithNavigation(
336
+ const settingsWithNav = buildSettingsForPropagation(
206
337
  currentSettings,
207
- config?.navigation,
338
+ config,
208
339
  i18n.language || 'en',
209
340
  );
210
341
  shellui.propagateMessage({
@@ -244,9 +375,9 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
244
375
  localStorage.setItem(STORAGE_KEY, JSON.stringify(newSettings));
245
376
  setSettings(newSettings);
246
377
  // Propagate to child iframes (sendMessageToParent does nothing in root)
247
- const settingsWithNav = buildSettingsWithNavigation(
378
+ const settingsWithNav = buildSettingsForPropagation(
248
379
  newSettings,
249
- config?.navigation,
380
+ config,
250
381
  i18n.language || 'en',
251
382
  );
252
383
  shellui.propagateMessage({
@@ -304,9 +435,9 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
304
435
  // If we're in the root window, update localStorage with defaults
305
436
  if (window.parent === window) {
306
437
  localStorage.setItem(STORAGE_KEY, JSON.stringify(newSettings));
307
- const settingsToPropagate = buildSettingsWithNavigation(
438
+ const settingsToPropagate = buildSettingsForPropagation(
308
439
  newSettings,
309
- config?.navigation,
440
+ config,
310
441
  i18n.language || 'en',
311
442
  );
312
443
  shellui.propagateMessage({
@@ -166,8 +166,8 @@ export const Appearance = () => {
166
166
  const { t } = useTranslation('settings');
167
167
  const { settings, updateSetting } = useSettings();
168
168
  const { config } = useConfig();
169
- const currentTheme = settings.appearance?.theme || 'system';
170
- const currentThemeName = settings.appearance?.themeName || 'default';
169
+ const currentTheme = settings.appearance?.colorScheme ?? 'system';
170
+ const currentThemeName = settings.appearance?.name ?? 'default';
171
171
 
172
172
  const [availableThemes, setAvailableThemes] = useState<ThemeDefinition[]>([]);
173
173
 
@@ -246,7 +246,7 @@ export const Appearance = () => {
246
246
  key={theme.value}
247
247
  variant={isSelected ? 'default' : 'outline'}
248
248
  onClick={() => {
249
- updateSetting('appearance', { theme: theme.value });
249
+ updateSetting('appearance', { colorScheme: theme.value });
250
250
  }}
251
251
  className={cn(
252
252
  'h-10 px-4 transition-all flex items-center gap-2 cursor-pointer',
@@ -283,7 +283,7 @@ export const Appearance = () => {
283
283
  <button
284
284
  key={theme.name}
285
285
  onClick={() => {
286
- updateSetting('appearance', { themeName: theme.name });
286
+ updateSetting('appearance', { name: theme.name });
287
287
  }}
288
288
  className={cn(
289
289
  'text-left transition-all cursor-pointer',
@@ -27,8 +27,8 @@ function applyThemeToDocument(isDark: boolean) {
27
27
  export function useTheme() {
28
28
  const { settings } = useSettings();
29
29
  const { config } = useConfig();
30
- const theme = settings.appearance?.theme || 'system';
31
- const themeName = settings.appearance?.themeName || 'default';
30
+ const colorScheme = settings.appearance?.colorScheme ?? 'system';
31
+ const themeName = settings.appearance?.name ?? 'default';
32
32
 
33
33
  // Apply theme immediately on mount (synchronously) to prevent empty colors
34
34
  // This ensures CSS variables are set before first render
@@ -47,11 +47,11 @@ export function useTheme() {
47
47
 
48
48
  if (themeDefinition) {
49
49
  const determineIsDark = () => {
50
- if (theme === 'system') {
50
+ if (colorScheme === 'system') {
51
51
  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
52
52
  return mediaQuery.matches;
53
53
  }
54
- return theme === 'dark';
54
+ return colorScheme === 'dark';
55
55
  };
56
56
  const isDark = determineIsDark();
57
57
  applyThemeToDocument(isDark);
@@ -62,10 +62,10 @@ export function useTheme() {
62
62
  const defaultTheme = getTheme('default');
63
63
  if (defaultTheme) {
64
64
  const isDark =
65
- theme === 'dark' ||
66
- (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
65
+ colorScheme === 'dark' ||
66
+ (colorScheme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
67
67
  applyTheme(defaultTheme, isDark);
68
68
  }
69
69
  }
70
- }, [theme, themeName, config]); // Run when theme, themeName, or config changes
70
+ }, [colorScheme, themeName, config]); // Run when colorScheme, themeName, or config changes
71
71
  }
@@ -24,9 +24,6 @@ const IndexRoute = lazy(() =>
24
24
  const NotFoundView = lazy(() =>
25
25
  import('../components/NotFoundView').then((m) => ({ default: m.NotFoundView })),
26
26
  );
27
- const AppPathView = lazy(() =>
28
- import('../components/AppPathView').then((m) => ({ default: m.AppPathView })),
29
- );
30
27
 
31
28
  function RouteFallback() {
32
29
  return (
@@ -62,15 +59,6 @@ export const createRoutes = (config: ShellUIConfig): RouteObject[] => {
62
59
  </Suspense>
63
60
  ),
64
61
  },
65
- {
66
- // App-path route: renders component-based nav items so they can be loaded in an iframe
67
- path: `${urls.appPath.replace(/^\//, '')}/:path/*`,
68
- element: (
69
- <Suspense fallback={<RouteFallback />}>
70
- <AppPathView />
71
- </Suspense>
72
- ),
73
- },
74
62
  {
75
63
  // Catch-all route
76
64
  path: '*',
@@ -1,31 +0,0 @@
1
- import { useParams } from 'react-router';
2
- import { useConfig } from '../features/config/useConfig';
3
- import { flattenNavigationItems } from '../features/layouts/utils';
4
- import { NotFoundView } from './NotFoundView';
5
-
6
- /**
7
- * Renders the React component for a navigation item when the app is loaded at /__app/:path.
8
- * Uses navItem.component (when config is not serialized) or shelluiComponents from context (injected by CLI when componentPath is set).
9
- */
10
- export const AppPathView = () => {
11
- const { config, shelluiComponents } = useConfig();
12
- const { path: pathParam } = useParams<{ path: string }>();
13
- const navigation = config?.navigation;
14
- const items = navigation?.length ? flattenNavigationItems(navigation) : [];
15
- const navItem = pathParam
16
- ? items.find(
17
- (item) =>
18
- item.path === pathParam ||
19
- (pathParam === 'home' && (item.path === '' || item.path === '/')),
20
- )
21
- : null;
22
-
23
- const pathKey = pathParam === 'home' || !pathParam ? 'home' : pathParam;
24
- const Component = navItem?.component ?? shelluiComponents?.[pathKey];
25
-
26
- if (!Component) {
27
- return <NotFoundView />;
28
- }
29
-
30
- return <Component />;
31
- };