@medalsocial/meda 1.4.0 → 1.6.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/README.md CHANGED
@@ -17,6 +17,28 @@ Peer deps: `react >= 19`, `react-dom >= 19`, and `lucide-react`.
17
17
  Meda ships a `styles.css` with its design tokens. Import it once in your entry stylesheet or entry script:
18
18
 
19
19
  ```css
20
+ @import 'tailwindcss';
21
+ @import '@medalsocial/meda/styles.css';
22
+
23
+ :root {
24
+ /* Consumer overrides go after meda so equal-specificity tokens win by source order. */
25
+ --color-brand-500: oklch(0.62 0.18 245);
26
+ --auth-gradient-primary: var(--color-brand-500);
27
+ }
28
+
29
+ .dark {
30
+ --color-brand-500: oklch(0.72 0.16 245);
31
+ }
32
+ ```
33
+
34
+ Avoid placing token overrides before the Meda import; `tokens.css` defines the package defaults and later declarations are what override them:
35
+
36
+ ```css
37
+ /* Wrong: meda's imported defaults overwrite this block. */
38
+ :root {
39
+ --color-brand-500: oklch(0.62 0.18 245);
40
+ }
41
+
20
42
  @import '@medalsocial/meda/styles.css';
21
43
  ```
22
44
 
@@ -58,11 +80,35 @@ import Link from 'next/link';
58
80
  mainItems,
59
81
  renderLink: ({ item, linkProps }) => <Link {...linkProps} href={item.to} prefetch />,
60
82
  }}
83
+ appTabs={{
84
+ renderLink: ({ app, linkProps }) =>
85
+ app.to ? <Link {...linkProps} href={app.to} prefetch /> : <a {...linkProps} />,
86
+ }}
61
87
  >
62
88
  {children}
63
89
  </AppShell>;
64
90
  ```
65
91
 
92
+ Workspace shells also expose chrome-level composition slots:
93
+
94
+ ```tsx
95
+ <AppShell
96
+ variant="workspace"
97
+ headerCenter={<SectionTabs />}
98
+ banners={<SystemHealthBanner />}
99
+ mainLayout="fullbleed"
100
+ mainClassName="marketing-main"
101
+ workspace={{
102
+ menuItems: [{ id: 'settings', label: 'Settings', href: '/settings' }],
103
+ menuFooter: <AccountSwitcher />,
104
+ }}
105
+ >
106
+ {children}
107
+ </AppShell>
108
+ ```
109
+
110
+ `workspace.menuItems`, `workspace.menuFooter`, and the theme toggle are available from the mobile Menu drawer. Use `mainLayout`/`mainClassName` when a workspace shell needs the same mobile chrome but a custom main scroll region, such as a full-bleed marketing page. `useCommands()` works from workspace descendants without manually mounting `CommandPalette`; lower-level primitive compositions can still mount `CommandPalette` directly.
111
+
66
112
  For app-scoped brand tokens:
67
113
 
68
114
  ```ts
@@ -1,7 +1,7 @@
1
1
  export const nextAppShellRecipe = {
2
2
  name: 'meda-next-app-shell',
3
3
  title: 'Meda Next AppShell',
4
- description: 'Copyable Next.js App Router shell adapter with next/link routing, route-owned panel views, and auth controls.',
4
+ description: 'Copyable Next.js App Router shell adapter with next/link routing, route-owned panel views, mobile workspace menus, full-bleed main layout hooks, and auth controls.',
5
5
  dependencies: ['@medalsocial/meda', 'lucide-react'],
6
6
  peerDependencies: ['next', 'react', 'react-dom'],
7
7
  cssVars: ['@medalsocial/meda/styles.css'],
@@ -25,9 +25,12 @@ import {
25
25
  MedaShellProvider,
26
26
  PanelViewsProvider,
27
27
  type AppDefinition,
28
+ type AppShellAppTabsConfig,
28
29
  type IconRailItem,
29
30
  type PanelView,
31
+ type ShellMainLayout,
30
32
  type WorkspaceDefinition,
33
+ type WorkspaceMenuItem,
31
34
  } from '@medalsocial/meda/shell'
32
35
 
33
36
  export function MedaNextWorkspaceShell({
@@ -37,6 +40,13 @@ export function MedaNextWorkspaceShell({
37
40
  activeIconId,
38
41
  panelViews = [],
39
42
  defaultPanelView,
43
+ appTabs,
44
+ headerCenter,
45
+ banners,
46
+ mainLayout,
47
+ mainClassName,
48
+ workspaceMenuItems,
49
+ workspaceMenuFooter,
40
50
  children,
41
51
  }: {
42
52
  workspace: WorkspaceDefinition
@@ -45,6 +55,13 @@ export function MedaNextWorkspaceShell({
45
55
  activeIconId?: string
46
56
  panelViews?: PanelView[]
47
57
  defaultPanelView?: string
58
+ appTabs?: AppShellAppTabsConfig
59
+ headerCenter?: ReactNode
60
+ banners?: ReactNode
61
+ mainLayout?: ShellMainLayout
62
+ mainClassName?: string
63
+ workspaceMenuItems?: WorkspaceMenuItem[]
64
+ workspaceMenuFooter?: ReactNode
48
65
  children: ReactNode
49
66
  }) {
50
67
  return (
@@ -58,6 +75,17 @@ export function MedaNextWorkspaceShell({
58
75
  <Link {...linkProps} href={item.to} prefetch />
59
76
  ),
60
77
  }}
78
+ appTabs={
79
+ appTabs ?? {
80
+ renderLink: ({ app, linkProps }) =>
81
+ app.to ? <Link {...linkProps} href={app.to} prefetch /> : <a {...linkProps} />,
82
+ }
83
+ }
84
+ headerCenter={headerCenter}
85
+ banners={banners}
86
+ mainLayout={mainLayout}
87
+ mainClassName={mainClassName}
88
+ workspace={{ menuItems: workspaceMenuItems, menuFooter: workspaceMenuFooter }}
61
89
  rightPanel={{ panelViews, defaultView: defaultPanelView }}
62
90
  >
63
91
  <PanelViewsProvider views={panelViews} defaultView={defaultPanelView}>
@@ -114,6 +142,7 @@ export function MedaNextAuthShell({
114
142
  accessibility: [
115
143
  'Every drawer and panel keeps its accessible name from AppShell and RightPanel.',
116
144
  'Custom link renderers must forward all linkProps to preserve aria-current, labels, handlers, and className.',
145
+ 'Workspace menu items render in desktop and mobile chrome, so critical actions stay reachable across viewports.',
117
146
  'Auth provider buttons keep the visible provider affordance separate from the accessible button name.',
118
147
  'Route-owned panel views should expose headings inside their rendered panel content.',
119
148
  'Reduced-motion behavior remains delegated to Meda shell motion tokens.',
@@ -121,6 +150,9 @@ export function MedaNextAuthShell({
121
150
  composition: [
122
151
  'MedaShellProvider owns workspace and app context for the copied shell adapter.',
123
152
  'AppShell receives route-owned rightPanel views on first render to avoid delayed panel UI.',
153
+ 'AppShellWorkspace mounts CommandPalette internally; route children can call useCommands without an extra shell-level mount.',
154
+ 'headerCenter and banners keep route-level chrome in the shell band instead of inside the main pane.',
155
+ 'mainLayout and mainClassName let full-bleed routes reuse AppShell mobile chrome without dropping down to primitives.',
124
156
  'PanelViewsProvider wraps children with the same panelViews and defaultPanelView for nested route registrations.',
125
157
  'renderLink composes Next Link by forwarding Meda linkProps before setting framework-specific props.',
126
158
  ],
@@ -7,7 +7,7 @@ function DefaultBrandMark() {
7
7
  }
8
8
  export function AppShellAuth({ children, title, description, brandName = 'Meda', brandMark, eyebrow, preview, actions, }) {
9
9
  const resolvedBrandMark = brandMark ?? _jsx(DefaultBrandMark, {});
10
- return (_jsxs("section", { "data-testid": "app-shell-auth", className: "grid min-h-screen overflow-hidden bg-background text-foreground lg:grid-cols-[minmax(0,1.08fr)_minmax(420px,0.92fr)]", children: [_jsxs("div", { className: "relative flex min-h-[24rem] flex-col overflow-hidden bg-[radial-gradient(circle_at_24%_18%,var(--color-info-500)_0,transparent_25%),radial-gradient(circle_at_84%_24%,var(--color-brand-400)_0,transparent_28%),linear-gradient(135deg,var(--color-brand-800),var(--color-brand-700)_42%,var(--color-brand-500))] px-6 py-6 text-white sm:min-h-[30rem] sm:px-8 lg:min-h-screen lg:px-10 lg:py-8", children: [_jsx("div", { className: "pointer-events-none absolute inset-0 opacity-[0.07]", style: {
10
+ return (_jsxs("section", { "data-testid": "app-shell-auth", className: "grid min-h-screen overflow-hidden bg-background text-foreground lg:grid-cols-[minmax(0,1.08fr)_minmax(420px,0.92fr)]", children: [_jsxs("div", { "data-meda-auth-marketing-panel": "", className: "relative flex min-h-[24rem] flex-col overflow-hidden bg-[radial-gradient(circle_at_24%_18%,var(--auth-gradient-primary)_0,transparent_25%),radial-gradient(circle_at_84%_24%,var(--auth-gradient-secondary)_0,transparent_28%),linear-gradient(135deg,var(--auth-gradient-base),var(--auth-gradient-secondary)_42%,var(--auth-gradient-primary))] px-6 py-6 text-white sm:min-h-[30rem] sm:px-8 lg:min-h-screen lg:px-10 lg:py-8", children: [_jsx("div", { className: "pointer-events-none absolute inset-0 opacity-[0.07]", style: {
11
11
  backgroundImage: "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")",
12
12
  } }), _jsxs("div", { className: "relative z-10 flex items-center gap-3", children: [resolvedBrandMark, _jsx("span", { className: "text-lg font-semibold tracking-normal", children: brandName })] }), _jsxs("div", { className: "relative z-10 flex flex-1 flex-col justify-center gap-8 py-10 lg:py-12", children: [_jsxs("div", { className: "max-w-2xl", children: [eyebrow && (_jsx("p", { className: "mb-4 text-sm font-semibold uppercase tracking-[0.12em] text-white/70", children: eyebrow })), _jsx("h1", { className: "text-4xl font-bold leading-tight tracking-normal text-white sm:text-5xl", children: title }), description && (_jsx("p", { className: "mt-4 max-w-xl text-base leading-7 text-white/72 sm:text-lg", children: description }))] }), _jsx("div", { className: "w-full max-w-3xl", children: preview ?? _jsx(DefaultAuthPreview, {}) })] })] }), _jsxs("div", { className: "relative flex min-h-screen flex-col bg-background px-6 py-6 sm:px-8 lg:px-12", children: [actions && _jsx("div", { className: "flex justify-end", children: actions }), _jsxs("div", { className: "mx-auto flex w-full max-w-md flex-1 flex-col justify-center py-10", children: [_jsx("div", { className: "mb-8 flex justify-center lg:hidden", children: resolvedBrandMark }), children] })] })] }));
13
13
  }
@@ -1,10 +1,16 @@
1
- import type { ReactNode } from 'react';
2
- import type { AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig } from './types.js';
1
+ import { type ReactNode } from 'react';
2
+ import type { AppShellAppTabsConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig, ShellMainLayout } from './types.js';
3
3
  export interface AppShellWorkspaceProps {
4
4
  iconRail?: AppShellIconRailConfig;
5
5
  contextRail?: AppShellContextRailConfig;
6
6
  rightPanel?: AppShellRightPanelConfig;
7
+ workspace?: AppShellWorkspaceConfig;
8
+ appTabs?: AppShellAppTabsConfig;
7
9
  globalActions?: ReactNode;
10
+ headerCenter?: ReactNode;
11
+ banners?: ReactNode;
12
+ mainLayout?: ShellMainLayout;
13
+ mainClassName?: string;
8
14
  children: ReactNode;
9
15
  }
10
- export declare function AppShellWorkspace({ iconRail, contextRail, rightPanel, globalActions, children, }: AppShellWorkspaceProps): import("react/jsx-runtime").JSX.Element;
16
+ export declare function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, appTabs, globalActions, headerCenter, banners, mainLayout, mainClassName, children, }: AppShellWorkspaceProps): import("react/jsx-runtime").JSX.Element;
@@ -1,6 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { LayoutGrid, Menu, PanelTop, Sparkles } from 'lucide-react';
4
+ import { useContext } from 'react';
5
+ import { CommandPalette, CommandRegistryContext } from './command-palette.js';
4
6
  import { ContextRail } from './context-rail.js';
5
7
  import { IconRail } from './icon-rail.js';
6
8
  import { MobileBottomNav } from './internal/mobile-bottom-nav.js';
@@ -12,16 +14,15 @@ import { ShellHeader } from './shell-header.js';
12
14
  import { ShellMain } from './shell-main.js';
13
15
  import { useShellViewport } from './use-shell-viewport.js';
14
16
  const EMPTY_PANEL_VIEWS = [];
15
- export function AppShellWorkspace({ iconRail, contextRail, rightPanel, globalActions, children, }) {
17
+ export function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, appTabs, globalActions, headerCenter, banners, mainLayout, mainClassName, children, }) {
16
18
  const viewport = useShellViewport();
17
19
  const isMobile = viewport === 'mobile';
18
20
  const staticPanelViews = rightPanel?.panelViews ?? EMPTY_PANEL_VIEWS;
19
21
  const resolvedRightPanel = useResolvedPanelViews(staticPanelViews, rightPanel?.defaultView);
20
22
  // Derive the bottom-nav items from the variant config so each button maps
21
- // to a drawer that actually has content. Without this filter, partial
22
- // configs (e.g. iconRail only) would show Module/Panels/AI buttons that
23
- // dispatch into the void.
24
- const navItems = buildMobileNavItems(iconRail, contextRail, resolvedRightPanel.panelViews);
23
+ // to a drawer that actually has content. Menu is always available because
24
+ // the mobile drawer now carries workspace actions and the theme toggle.
25
+ const navItems = buildMobileNavItems(contextRail, resolvedRightPanel.panelViews);
25
26
  const hasDrawerContent = navItems.length > 0;
26
27
  // Mobile menu drawer needs both main and utility items — desktop IconRail
27
28
  // shows both, so dropping utilityItems here would orphan items like Help/
@@ -37,13 +38,14 @@ export function AppShellWorkspace({ iconRail, contextRail, rightPanel, globalAct
37
38
  // rendered without an explicit-height ancestor (tests, direct imports). The
38
39
  // <AppShell> wrapper already enforces h-screen for the workspace variant, so
39
40
  // nested viewport-height divs collapse cleanly — no double-scroll.
40
- return (_jsxs("div", { className: "flex h-screen flex-col", children: [isMobile ? (_jsx(MobileHeader, { globalActions: globalActions })) : (_jsx(ShellHeader, { globalActions: globalActions })), _jsxs("div", { className: "relative flex flex-1 overflow-hidden", children: [!isMobile && iconRail && (_jsx(IconRail, { mainItems: iconRail.mainItems, utilityItems: iconRail.utilityItems, footer: iconRail.footer, activeId: iconRail.activeId, renderLink: iconRail.renderLink })), !isMobile && contextRail && (_jsx(ContextRail, { appId: contextRail.appId, module: contextRail.module, activeItemId: contextRail.activeItemId })), _jsx(ShellMain, { layout: "workspace", children: children }), !isMobile && resolvedRightPanel.panelViews.length > 0 && (_jsx(RightPanel, { panelViews: staticPanelViews, defaultView: rightPanel?.defaultView }))] }), isMobile && hasDrawerContent && _jsx(MobileBottomNav, { items: navItems }), isMobile && hasDrawerContent && (_jsx(MobileDrawers, { menuItems: mobileMenuItems, menuActiveId: iconRail?.activeId, menuRenderLink: iconRail?.renderLink, module: contextRail?.module, moduleAppId: contextRail?.appId, panelViews: resolvedRightPanel.panelViews, defaultView: resolvedRightPanel.defaultView }))] }));
41
+ const commandRegistry = useContext(CommandRegistryContext);
42
+ const shell = (_jsxs("div", { className: "flex h-screen flex-col", children: [isMobile ? (_jsx(MobileHeader, { globalActions: globalActions })) : (_jsx(ShellHeader, { globalActions: globalActions, headerCenter: headerCenter, appTabsRenderLink: appTabs?.renderLink, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter })), banners ? (_jsx("div", { "data-meda-banners": "", className: "flex-shrink-0", children: banners })) : (false), _jsxs("div", { className: "relative flex flex-1 overflow-hidden", children: [!isMobile && iconRail && (_jsx(IconRail, { mainItems: iconRail.mainItems, utilityItems: iconRail.utilityItems, footer: iconRail.footer, activeId: iconRail.activeId, renderLink: iconRail.renderLink })), !isMobile && contextRail && (_jsx(ContextRail, { appId: contextRail.appId, module: contextRail.module, activeItemId: contextRail.activeItemId })), _jsx(ShellMain, { layout: mainLayout ?? 'workspace', className: mainClassName, children: children }), !isMobile && resolvedRightPanel.panelViews.length > 0 && (_jsx(RightPanel, { panelViews: staticPanelViews, defaultView: rightPanel?.defaultView }))] }), isMobile && hasDrawerContent && _jsx(MobileBottomNav, { items: navItems }), isMobile && hasDrawerContent && (_jsx(MobileDrawers, { menuItems: mobileMenuItems, menuActiveId: iconRail?.activeId, menuRenderLink: iconRail?.renderLink, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter, module: contextRail?.module, moduleAppId: contextRail?.appId, panelViews: resolvedRightPanel.panelViews, defaultView: resolvedRightPanel.defaultView }))] }));
43
+ return commandRegistry ? shell : _jsx(CommandPalette, { children: shell });
41
44
  }
42
- function buildMobileNavItems(iconRail, contextRail, panelViews) {
43
- const items = [];
44
- if (iconRail) {
45
- items.push({ id: 'menu', label: 'Menu', icon: Menu, opens: 'menu-drawer' });
46
- }
45
+ function buildMobileNavItems(contextRail, panelViews) {
46
+ const items = [
47
+ { id: 'menu', label: 'Menu', icon: Menu, opens: 'menu-drawer' },
48
+ ];
47
49
  if (contextRail?.module &&
48
50
  ((contextRail.module.items ?? []).length > 0 || Boolean(contextRail.module.render))) {
49
51
  items.push({ id: 'module', label: 'Module', icon: LayoutGrid, opens: 'module-drawer' });
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
- import type { AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig } from './types.js';
2
+ import type { AppShellAppTabsConfig, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig, ShellMainLayout } from './types.js';
3
3
  interface AppShellBaseProps {
4
4
  children: ReactNode;
5
5
  className?: string;
@@ -15,7 +15,37 @@ export type AppShellProps = AppShellBaseProps & ({
15
15
  iconRail?: AppShellIconRailConfig;
16
16
  contextRail?: AppShellContextRailConfig;
17
17
  rightPanel?: AppShellRightPanelConfig;
18
+ /**
19
+ * Configurable workspace dropdown — replaces the package-default
20
+ * "Manage workspaces / Settings / Profile / Sign out" entries when
21
+ * `menuItems` is provided. The theme toggle is preserved automatically.
22
+ */
23
+ workspace?: AppShellWorkspaceConfig;
24
+ /**
25
+ * Optional application-tab rendering config, used for router-specific
26
+ * link integration.
27
+ */
28
+ appTabs?: AppShellAppTabsConfig;
18
29
  globalActions?: ReactNode;
30
+ /**
31
+ * Optional center-region header content. Replaces the default
32
+ * application tabs when provided.
33
+ */
34
+ headerCenter?: ReactNode;
35
+ /**
36
+ * Optional chrome-level content rendered below the header and above
37
+ * the workspace rail row.
38
+ */
39
+ banners?: ReactNode;
40
+ /**
41
+ * Layout passed through to the workspace shell's main scroll region.
42
+ * Defaults to `workspace`.
43
+ */
44
+ mainLayout?: ShellMainLayout;
45
+ /**
46
+ * Optional className for the workspace shell's main scroll region.
47
+ */
48
+ mainClassName?: string;
19
49
  } | {
20
50
  variant: 'chat';
21
51
  globalActions?: ReactNode;
@@ -16,7 +16,7 @@ export function AppShell(props) {
16
16
  case 'auth':
17
17
  return wrapper(_jsx(AppShellAuth, { ...resolveAuthConfig(props), children: props.children }));
18
18
  case 'workspace':
19
- return wrapper(_jsx(AppShellWorkspace, { iconRail: props.iconRail, contextRail: props.contextRail, rightPanel: props.rightPanel, globalActions: props.globalActions, children: props.children }));
19
+ return wrapper(_jsx(AppShellWorkspace, { iconRail: props.iconRail, contextRail: props.contextRail, rightPanel: props.rightPanel, workspace: props.workspace, appTabs: props.appTabs, globalActions: props.globalActions, headerCenter: props.headerCenter, banners: props.banners, mainLayout: props.mainLayout, mainClassName: props.mainClassName, children: props.children }));
20
20
  case 'chat':
21
21
  return wrapper(_jsx(AppShellChat, { globalActions: props.globalActions, children: props.children }));
22
22
  }
@@ -20,11 +20,12 @@ export type { RailDropZonesProps } from './rail-drop-zones.js';
20
20
  export { RailDropZones } from './rail-drop-zones.js';
21
21
  export { ResizableHandle, ResizableShell, ResizableShellPanel } from './resizable-shell.js';
22
22
  export { RightPanel } from './right-panel.js';
23
+ export type { AppTabsProps } from './shell-header.js';
23
24
  export { AppTabs, PanelToggle, ShellHeader, WorkspaceSwitcher } from './shell-header.js';
24
25
  export { ShellMain } from './shell-main.js';
25
26
  export type { MedaShellProviderProps } from './shell-provider.js';
26
27
  export { MedaShellProvider, useMedaShell, useShellSelection } from './shell-provider.js';
27
28
  export { DefaultThemeProvider, ThemeToggle, useTheme } from './theme.js';
28
29
  export { NextThemesAdapter } from './theme-next-themes.js';
29
- export type { AppDefinition, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellVariant, CommandDefinition, ContextItem, ContextModule, MobileBottomNavItem, PanelMode, PanelView, ShellLinkRenderArgs, ShellMainLayout, ShellRenderContext, ShellViewport, ThemeAdapter, WorkspaceDefinition, } from './types.js';
30
+ export type { AppDefinition, AppShellAppTabsConfig, AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellVariant, AppShellWorkspaceConfig, AppTabRenderLinkArgs, CommandDefinition, ContextItem, ContextModule, MobileBottomNavItem, PanelMode, PanelView, ShellLinkRenderArgs, ShellMainLayout, ShellRenderContext, ShellViewport, ThemeAdapter, WorkspaceDefinition, WorkspaceMenuItem, } from './types.js';
30
31
  export { useShellViewport } from './use-shell-viewport.js';
@@ -24,7 +24,6 @@ export { RailDropSlot } from './rail-drop-slot.js';
24
24
  export { RailDropZones } from './rail-drop-zones.js';
25
25
  export { ResizableHandle, ResizableShell, ResizableShellPanel } from './resizable-shell.js';
26
26
  export { RightPanel } from './right-panel.js';
27
- // Header (and its individual children for advanced composition)
28
27
  export { AppTabs, PanelToggle, ShellHeader, WorkspaceSwitcher } from './shell-header.js';
29
28
  export { ShellMain } from './shell-main.js';
30
29
  // Provider + hooks
@@ -1,6 +1,6 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { IconRailItem, IconRailProps } from '../icon-rail.js';
3
- import type { ContextModule, PanelView } from '../types.js';
3
+ import type { ContextModule, PanelView, WorkspaceMenuItem } from '../types.js';
4
4
  export interface MobileDrawersProps {
5
5
  /** Menu drawer source (icon-rail items). */
6
6
  menuItems?: IconRailItem[];
@@ -8,6 +8,10 @@ export interface MobileDrawersProps {
8
8
  menuActiveId?: string;
9
9
  /** Custom menu link renderer, sourced from icon rail config. */
10
10
  menuRenderLink?: IconRailProps['renderLink'];
11
+ /** Workspace-level menu items rendered after icon rail links on mobile. */
12
+ workspaceMenuItems?: WorkspaceMenuItem[];
13
+ /** Workspace-level footer rendered after workspace items and the theme toggle. */
14
+ workspaceMenuFooter?: ReactNode;
11
15
  /** Module drawer source (current app's context-rail module). */
12
16
  module?: ContextModule;
13
17
  /** App id used when rendering module custom content. */
@@ -29,4 +33,4 @@ export interface MobileDrawersProps {
29
33
  * any custom-content drawers. Mount once near the AppShell root; drawers
30
34
  * open/close via `ctx.mobileDrawer.open` provider state.
31
35
  */
32
- export declare function MobileDrawers({ menuItems, menuActiveId, menuRenderLink, module, moduleAppId, panelViews, defaultView, customContent, }: MobileDrawersProps): import("react/jsx-runtime").JSX.Element;
36
+ export declare function MobileDrawers({ menuItems, menuActiveId, menuRenderLink, workspaceMenuItems, workspaceMenuFooter, module, moduleAppId, panelViews, defaultView, customContent, }: MobileDrawersProps): import("react/jsx-runtime").JSX.Element;
@@ -1,15 +1,17 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { cloneElement, isValidElement, useEffect } from 'react';
3
+ import { Monitor, Moon, Sun } from 'lucide-react';
4
+ import { cloneElement, createElement, Fragment, isValidElement, useEffect, } from 'react';
4
5
  import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from '../../components/ui/drawer.js';
5
6
  import { cn } from '../../lib/utils.js';
6
7
  import { useMedaShell } from '../shell-provider.js';
8
+ import { useTheme } from '../theme.js';
7
9
  /**
8
10
  * Renders all four mobile drawer slots (Menu / Module / Panels / AI) plus
9
11
  * any custom-content drawers. Mount once near the AppShell root; drawers
10
12
  * open/close via `ctx.mobileDrawer.open` provider state.
11
13
  */
12
- export function MobileDrawers({ menuItems = [], menuActiveId, menuRenderLink, module, moduleAppId, panelViews = [], defaultView, customContent = {}, }) {
14
+ export function MobileDrawers({ menuItems = [], menuActiveId, menuRenderLink, workspaceMenuItems, workspaceMenuFooter, module, moduleAppId, panelViews = [], defaultView, customContent = {}, }) {
13
15
  const ctx = useMedaShell();
14
16
  const open = ctx.mobileDrawer.open;
15
17
  const setOpen = ctx.mobileDrawer.setOpen;
@@ -22,13 +24,15 @@ export function MobileDrawers({ menuItems = [], menuActiveId, menuRenderLink, mo
22
24
  workspaceId: ctx.workspace.id,
23
25
  appId: moduleAppId ?? ctx.activeAppId,
24
26
  };
25
- return (_jsxs(_Fragment, { children: [_jsx(MenuDrawer, { open: open === 'menu-drawer', onClose: close, items: menuItems, activeId: menuActiveId, renderLink: menuRenderLink }), _jsx(ModuleDrawer, { open: open === 'module-drawer', onClose: close, module: module, renderCtx: moduleRenderCtx }), _jsx(PanelsDrawer, { open: open === 'panels-drawer', onClose: close, panelViews: panelViews, defaultView: defaultView, renderCtx: renderCtx }), _jsx(AiDrawer, { open: open === 'ai-drawer', onClose: close, panelViews: panelViews, renderCtx: renderCtx }), Object.entries(customContent).map(([id, renderFn]) => (_jsx(Drawer, { open: open === id, onOpenChange: (o) => !o && close(), direction: "bottom", children: _jsx(DrawerContent, { children: renderFn(close) }) }, id)))] }));
27
+ return (_jsxs(_Fragment, { children: [_jsx(MenuDrawer, { open: open === 'menu-drawer', onClose: close, items: menuItems, activeId: menuActiveId, renderLink: menuRenderLink, workspaceItems: workspaceMenuItems, workspaceFooter: workspaceMenuFooter }), _jsx(ModuleDrawer, { open: open === 'module-drawer', onClose: close, module: module, renderCtx: moduleRenderCtx }), _jsx(PanelsDrawer, { open: open === 'panels-drawer', onClose: close, panelViews: panelViews, defaultView: defaultView, renderCtx: renderCtx }), _jsx(AiDrawer, { open: open === 'ai-drawer', onClose: close, panelViews: panelViews, renderCtx: renderCtx }), Object.entries(customContent).map(([id, renderFn]) => (_jsx(Drawer, { open: open === id, onOpenChange: (o) => !o && close(), direction: "bottom", children: _jsx(DrawerContent, { children: renderFn(close) }) }, id)))] }));
26
28
  }
27
29
  // ---------------------------------------------------------------------------
28
30
  // Internal sub-drawers
29
31
  // ---------------------------------------------------------------------------
30
- function MenuDrawer({ open, onClose, items, activeId, renderLink, }) {
31
- return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "left", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: "Menu" }), _jsx(DrawerDescription, { className: "sr-only", children: "Switch between primary app areas." })] }), _jsx("nav", { className: "flex flex-col gap-0.5 p-2", children: items.map((item) => (_jsx(MenuDrawerItem, { item: item, isActive: item.id === activeId, onClose: onClose, renderLink: renderLink }, item.id))) })] }) }));
32
+ function MenuDrawer({ open, onClose, items, activeId, renderLink, workspaceItems, workspaceFooter, }) {
33
+ const hasIconItems = items.length > 0;
34
+ const hasWorkspaceItems = Array.isArray(workspaceItems) && workspaceItems.length > 0;
35
+ return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "left", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: "Menu" }), _jsx(DrawerDescription, { className: "sr-only", children: "Switch between primary app areas." })] }), _jsxs("div", { className: "flex flex-col gap-2 p-2", children: [hasIconItems && (_jsx("nav", { "aria-label": "Primary navigation", className: "flex flex-col gap-0.5", children: items.map((item) => (_jsx(MenuDrawerItem, { item: item, isActive: item.id === activeId, onClose: onClose, renderLink: renderLink }, item.id))) })), (hasIconItems || hasWorkspaceItems) && _jsx("div", { className: "h-px bg-border" }), _jsxs("nav", { "aria-label": "Workspace menu", className: "flex flex-col gap-0.5", children: [workspaceItems?.map((item) => (_jsxs(Fragment, { children: [_jsx(WorkspaceMenuDrawerItem, { item: item, onClose: onClose }), item.separatorAfter && _jsx("div", { className: "my-1 h-px bg-border" })] }, item.id))), _jsx(MobileThemeMenuItem, { onClose: onClose })] }), workspaceFooter] })] }) }));
32
36
  }
33
37
  const menuItemClassName = 'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground';
34
38
  function MenuDrawerItem({ item, isActive, onClose, renderLink, }) {
@@ -63,6 +67,49 @@ function closeAfterLinkClick(link, onClose) {
63
67
  },
64
68
  });
65
69
  }
70
+ function renderWorkspaceIcon(icon) {
71
+ if (icon == null)
72
+ return null;
73
+ if (isValidElement(icon))
74
+ return icon;
75
+ if (typeof icon === 'function') {
76
+ return createElement(icon, { size: 18, 'aria-hidden': true });
77
+ }
78
+ if (typeof icon === 'object') {
79
+ const candidate = icon;
80
+ if (candidate.$$typeof != null) {
81
+ return createElement(icon, { size: 18, 'aria-hidden': true });
82
+ }
83
+ }
84
+ return icon;
85
+ }
86
+ function WorkspaceMenuDrawerItem({ item, onClose, }) {
87
+ const children = (_jsxs(_Fragment, { children: [renderWorkspaceIcon(item.icon), _jsx("span", { children: item.label })] }));
88
+ const className = cn(menuItemClassName, item.variant === 'destructive' && 'text-destructive hover:text-destructive');
89
+ const handleClick = () => {
90
+ item.onClick?.();
91
+ onClose();
92
+ };
93
+ if (item.href != null) {
94
+ return closeAfterLinkClick(_jsx("a", { href: item.href, className: className, "data-variant": item.variant ?? 'default', onClick: () => item.onClick?.(), children: children }), onClose);
95
+ }
96
+ return (_jsx("button", { type: "button", "data-variant": item.variant ?? 'default', className: className, onClick: handleClick, children: children }));
97
+ }
98
+ const NEXT_THEME = { light: 'dark', dark: 'system', system: 'light' };
99
+ const THEME_ICON = { light: Sun, dark: Moon, system: Monitor };
100
+ const THEME_LABEL = {
101
+ light: 'Switch to dark theme',
102
+ dark: 'Switch to system theme',
103
+ system: 'Switch to light theme',
104
+ };
105
+ function MobileThemeMenuItem({ onClose }) {
106
+ const { theme, setTheme } = useTheme();
107
+ const Icon = THEME_ICON[theme];
108
+ return (_jsxs("button", { type: "button", "aria-label": THEME_LABEL[theme], className: menuItemClassName, onClick: () => {
109
+ setTheme(NEXT_THEME[theme]);
110
+ onClose();
111
+ }, children: [_jsx(Icon, { size: 18, "aria-hidden": "true" }), _jsx("span", { children: THEME_LABEL[theme] })] }));
112
+ }
66
113
  function ModuleDrawer({ open, onClose, module, renderCtx, }) {
67
114
  const items = module?.items ?? [];
68
115
  if (!module || (items.length === 0 && !module.render))
@@ -90,7 +137,7 @@ function PanelsDrawer({ open, onClose, panelViews, defaultView, renderCtx, }) {
90
137
  const active = panelViews.find((v) => v.id === activeView) ??
91
138
  (defaultView ? panelViews.find((v) => v.id === defaultView) : undefined) ??
92
139
  panelViews[0];
93
- return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "bottom", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: active?.label ?? 'Panels' }), _jsx(DrawerDescription, { className: "sr-only", children: "Contextual panels for the current module." })] }), panelViews.length > 1 && (_jsx("div", { className: "flex items-center gap-1 border-b border-border px-3 py-2", children: panelViews.map((view) => (_jsx("button", { type: "button", onClick: () => ctx.panel.setActiveView(view.id), "aria-current": view.id === activeView ? 'true' : undefined, className: cn('rounded-md px-2 py-1 text-xs', view.id === activeView
140
+ return (_jsx(Drawer, { open: open, onOpenChange: (o) => !o && onClose(), direction: "bottom", children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: active?.label ?? 'Panels' }), _jsx(DrawerDescription, { className: "sr-only", children: "Contextual panels for the current module." })] }), panelViews.length > 1 && (_jsx("div", { className: "flex items-center gap-1 border-b border-border px-3 py-2", children: panelViews.map((view) => (_jsx("button", { type: "button", onClick: () => ctx.panel.setActiveView(view.id), "aria-current": view.id === activeView ? 'true' : undefined, className: cn('rounded-md px-2 py-1 text-sm', view.id === activeView
94
141
  ? 'bg-accent text-accent-foreground'
95
142
  : 'text-muted-foreground hover:bg-accent'), children: view.label }, view.id))) })), _jsx("div", { className: "flex-1 overflow-y-auto p-3", children: active?.render(renderCtx) })] }) }));
96
143
  }
@@ -1,12 +1,42 @@
1
- import type { ReactNode } from 'react';
1
+ import { type ReactNode } from 'react';
2
+ import type { AppShellAppTabsConfig, WorkspaceMenuItem } from './types.js';
2
3
  export interface WorkspaceSwitcherProps {
4
+ /**
5
+ * Configurable dropdown items. When provided, REPLACES the default
6
+ * "Manage workspaces / Settings / Profile / Sign out" entries. The theme
7
+ * toggle is still inserted automatically by meda — between the items and
8
+ * the footer — so consumers don't have to reimplement theme cycling.
9
+ *
10
+ * When omitted, the package-default items render (1.x behavior).
11
+ */
12
+ menuItems?: WorkspaceMenuItem[];
13
+ /**
14
+ * Extra content rendered after all items and the theme toggle. Backwards-
15
+ * compatible alias for the legacy `workspaceMenuFooter` prop.
16
+ */
17
+ menuFooter?: ReactNode;
18
+ /** @deprecated Use `menuFooter` instead. */
3
19
  workspaceMenuFooter?: ReactNode;
4
20
  }
5
- export declare function WorkspaceSwitcher({ workspaceMenuFooter }?: WorkspaceSwitcherProps): import("react/jsx-runtime").JSX.Element;
6
- export declare function AppTabs(): import("react/jsx-runtime").JSX.Element;
21
+ export declare function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter, }?: WorkspaceSwitcherProps): import("react/jsx-runtime").JSX.Element;
22
+ export interface AppTabsProps extends AppShellAppTabsConfig {
23
+ }
24
+ export declare function AppTabs({ renderLink }?: AppTabsProps): import("react/jsx-runtime").JSX.Element;
7
25
  export declare function PanelToggle(): import("react/jsx-runtime").JSX.Element;
8
26
  export interface ShellHeaderProps {
9
27
  globalActions?: ReactNode;
28
+ /**
29
+ * Optional center-region content. Replaces the default application tabs when
30
+ * provided.
31
+ */
32
+ headerCenter?: ReactNode;
33
+ appTabsRenderLink?: AppShellAppTabsConfig['renderLink'];
10
34
  className?: string;
35
+ /**
36
+ * Forwarded to the internal `<WorkspaceSwitcher>`. See
37
+ * `WorkspaceSwitcherProps` for the full shape.
38
+ */
39
+ workspaceMenuItems?: WorkspaceMenuItem[];
40
+ workspaceMenuFooter?: ReactNode;
11
41
  }
12
- export declare function ShellHeader({ globalActions, className }?: ShellHeaderProps): import("react/jsx-runtime").JSX.Element | null;
42
+ export declare function ShellHeader({ globalActions, headerCenter, appTabsRenderLink, className, workspaceMenuItems, workspaceMenuFooter, }?: ShellHeaderProps): import("react/jsx-runtime").JSX.Element | null;
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { ChevronDown, Monitor, Moon, PanelRightClose, PanelRightOpen, Sun } from 'lucide-react';
4
+ import { createElement, Fragment, isValidElement } from 'react';
4
5
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '../components/ui/dropdown-menu.js';
5
6
  import { cn } from '../lib/utils.js';
6
7
  import { useMedaShell } from './shell-provider.js';
@@ -18,25 +19,71 @@ function ThemeToggleMenuItem() {
18
19
  const Icon = THEME_ICON[theme];
19
20
  return (_jsxs(DropdownMenuItem, { onClick: () => setTheme(NEXT_THEME[theme]), children: [_jsx(Icon, { size: 16, "aria-hidden": "true" }), THEME_LABEL[theme]] }));
20
21
  }
21
- export function WorkspaceSwitcher({ workspaceMenuFooter } = {}) {
22
+ function renderShellIcon(icon) {
23
+ if (icon == null)
24
+ return null;
25
+ if (isValidElement(icon))
26
+ return icon;
27
+ if (typeof icon === 'function') {
28
+ return createElement(icon, { size: 16, 'aria-hidden': true });
29
+ }
30
+ // forwardRef/memo components are objects carrying a `$$typeof` symbol —
31
+ // treat them like Lucide components. Plain ReactNode objects (arrays,
32
+ // iterables, promises) have no `$$typeof` and are rendered as-is so they
33
+ // don't crash createElement with "Element type is invalid".
34
+ if (typeof icon === 'object' && icon !== null) {
35
+ const candidate = icon;
36
+ if (candidate.$$typeof != null) {
37
+ return createElement(icon, {
38
+ size: 16,
39
+ 'aria-hidden': true,
40
+ });
41
+ }
42
+ }
43
+ return icon;
44
+ }
45
+ function renderConfiguredItem(item) {
46
+ const handleSelect = () => item.onClick?.();
47
+ // Childless anchor — Base UI merges the DropdownMenuItem's children into the
48
+ // cloned render element. Passing children here would override the icon +
49
+ // label children below and configured icons would silently disappear.
50
+ // biome-ignore lint/a11y/useAnchorContent: children are injected at render time by Base UI's `render` prop
51
+ const renderLink = item.href != null ? _jsx("a", { href: item.href }) : undefined;
52
+ return (_jsxs(DropdownMenuItem, { render: renderLink, "data-variant": item.variant ?? 'default', className: item.variant === 'destructive' ? 'text-destructive' : undefined, onClick: handleSelect, children: [renderShellIcon(item.icon), item.label] }));
53
+ }
54
+ export function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter, } = {}) {
22
55
  const { workspace, workspaces } = useMedaShell();
23
- return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", className: "flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-accent" }), children: [workspace.icon != null && (_jsx("span", { className: "shrink-0", "aria-hidden": "true", children: workspace.icon })), _jsx("span", { children: workspace.name }), _jsx(ChevronDown, { size: 14, "aria-hidden": "true" })] }), _jsxs(DropdownMenuContent, { className: "min-w-[200px]", children: [workspaces.length > 0 && (_jsxs(_Fragment, { children: [workspaces.map((ws) => (_jsxs(DropdownMenuItem, { children: [ws.icon != null && _jsx("span", { "aria-hidden": "true", children: ws.icon }), ws.name] }, ws.id))), _jsx(DropdownMenuSeparator, {})] })), _jsx(DropdownMenuItem, { children: "Manage workspaces" }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { children: "Settings" }), _jsx(DropdownMenuItem, { children: "Profile" }), _jsx(DropdownMenuSeparator, {}), _jsx(ThemeToggleMenuItem, {}), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { children: "Sign out" }), workspaceMenuFooter] })] }));
56
+ const resolvedFooter = menuFooter ?? workspaceMenuFooter;
57
+ const useConfiguredItems = Array.isArray(menuItems);
58
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", className: "flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-accent" }), children: [workspace.icon != null && (_jsx("span", { className: "shrink-0", "aria-hidden": "true", children: workspace.icon })), _jsx("span", { children: workspace.name }), _jsx(ChevronDown, { size: 14, "aria-hidden": "true" })] }), _jsxs(DropdownMenuContent, { className: "min-w-[200px]", children: [workspaces.length > 0 && (_jsxs(_Fragment, { children: [workspaces.map((ws) => (_jsxs(DropdownMenuItem, { children: [ws.icon != null && _jsx("span", { "aria-hidden": "true", children: ws.icon }), ws.name] }, ws.id))), _jsx(DropdownMenuSeparator, {})] })), useConfiguredItems ? (menuItems.map((item) => (_jsxs(Fragment, { children: [renderConfiguredItem(item), item.separatorAfter && _jsx(DropdownMenuSeparator, {})] }, item.id)))) : (_jsxs(_Fragment, { children: [_jsx(DropdownMenuItem, { children: "Manage workspaces" }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { children: "Settings" }), _jsx(DropdownMenuItem, { children: "Profile" })] })), _jsx(DropdownMenuSeparator, {}), _jsx(ThemeToggleMenuItem, {}), !useConfiguredItems && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { children: "Sign out" })] })), resolvedFooter] })] }));
24
59
  }
25
- // ---------------------------------------------------------------------------
26
- // AppTabs
27
- // ---------------------------------------------------------------------------
28
- export function AppTabs() {
60
+ export function AppTabs({ renderLink } = {}) {
29
61
  const { apps, activeAppId, setActiveApp } = useMedaShell();
30
62
  return (_jsx("nav", { "aria-label": "Applications", className: "flex items-center", children: apps.map((app) => {
31
63
  const isActive = app.id === activeAppId;
32
- const Icon = app.icon;
33
- return (_jsxs("button", { type: "button", "aria-current": isActive ? 'page' : undefined, onClick: () => {
34
- setActiveApp(app.id);
35
- // TODO(Phase 18.x): renderLink integration so clicking a tab also navigates
36
- // the consumer's router; setActiveApp alone updates context.
37
- }, onMouseEnter: () => { }, className: cn('flex items-center gap-1.5 px-3 py-2 text-sm font-medium transition-colors', isActive
38
- ? 'border-b-2 border-primary text-foreground'
39
- : 'text-muted-foreground hover:text-foreground'), children: [_jsx(Icon, { size: 16, "aria-hidden": "true" }), app.label] }, app.id));
64
+ const className = cn('flex items-center gap-1.5 px-3 py-2 text-sm font-medium transition-colors', isActive
65
+ ? 'border-b-2 border-primary text-foreground'
66
+ : 'text-muted-foreground hover:text-foreground');
67
+ const children = (_jsxs(_Fragment, { children: [renderShellIcon(app.icon), app.label] }));
68
+ const handleClick = () => {
69
+ setActiveApp(app.id);
70
+ };
71
+ if (renderLink && app.to) {
72
+ return (_jsx(Fragment, { children: renderLink({
73
+ app,
74
+ isActive,
75
+ className,
76
+ children,
77
+ linkProps: {
78
+ href: app.to,
79
+ className,
80
+ children,
81
+ 'aria-current': isActive ? 'page' : undefined,
82
+ onClick: handleClick,
83
+ },
84
+ }) }, app.id));
85
+ }
86
+ return (_jsx("button", { type: "button", "aria-current": isActive ? 'page' : undefined, onClick: handleClick, onMouseEnter: () => { }, className: className, children: children }, app.id));
40
87
  }) }));
41
88
  }
42
89
  // ---------------------------------------------------------------------------
@@ -50,9 +97,9 @@ export function PanelToggle() {
50
97
  };
51
98
  return (_jsx("button", { type: "button", "aria-label": isOpen ? 'Close right panel' : 'Open right panel', onClick: handleClick, className: cn('inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors', isOpen ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent'), children: isOpen ? (_jsx(PanelRightClose, { size: 18, "aria-hidden": "true" })) : (_jsx(PanelRightOpen, { size: 18, "aria-hidden": "true" })) }));
52
99
  }
53
- export function ShellHeader({ globalActions, className } = {}) {
100
+ export function ShellHeader({ globalActions, headerCenter, appTabsRenderLink, className, workspaceMenuItems, workspaceMenuFooter, } = {}) {
54
101
  const band = useShellViewport();
55
102
  if (band === 'mobile')
56
103
  return null;
57
- return (_jsxs("header", { className: cn('flex h-[var(--shell-header-height)] w-full items-center justify-between', 'border-b border-border bg-background px-3', className), children: [_jsxs("div", { className: "flex items-center", children: [_jsx(WorkspaceSwitcher, {}), _jsx(AppTabs, {})] }), _jsxs("div", { className: "flex items-center gap-2", children: [globalActions, _jsx(PanelToggle, {})] })] }));
104
+ return (_jsxs("header", { className: cn('flex h-[var(--shell-header-height)] w-full items-center justify-between', 'gap-3 border-b border-border bg-background px-3', className), children: [_jsx("div", { className: "flex shrink-0 items-center", children: _jsx(WorkspaceSwitcher, { menuItems: workspaceMenuItems, menuFooter: workspaceMenuFooter }) }), _jsx("div", { className: "flex min-w-0 flex-1 items-center", children: headerCenter !== undefined ? headerCenter : _jsx(AppTabs, { renderLink: appTabsRenderLink }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [globalActions, _jsx(PanelToggle, {})] })] }));
58
105
  }
@@ -8,9 +8,8 @@ import { useShellViewport } from './use-shell-viewport.js';
8
8
  // ---------------------------------------------------------------------------
9
9
  // Theme adapter wiring
10
10
  // ---------------------------------------------------------------------------
11
- // Lazy-loaded so that importing next-themes only happens when the consumer
12
- // passes themeAdapter='next-themes'. Default-adapter consumers never trigger
13
- // the import.
11
+ // Lazy-loaded so default-adapter consumers do not pay for the compatibility
12
+ // bridge used when themeAdapter='next-themes'.
14
13
  const NextThemesAdapter = lazy(() => import('./theme-next-themes.js').then((m) => ({ default: m.NextThemesAdapter })));
15
14
  function CustomThemeBridge({ adapter, children }) {
16
15
  return _jsx(ThemeCtx.Provider, { value: adapter, children: children });
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react';
1
+ import { type ReactNode } from 'react';
2
2
  export declare function NextThemesAdapter({ children }: {
3
3
  children: ReactNode;
4
4
  }): import("react/jsx-runtime").JSX.Element;
@@ -1,24 +1,115 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { ThemeProvider as NextThemesProvider, useTheme as useNextTheme } from 'next-themes';
3
+ import { useCallback, useEffect, useInsertionEffect, useMemo, useState, } from 'react';
4
4
  import { ThemeCtx } from './theme.js';
5
+ const STORAGE_KEY = 'theme';
6
+ const MEDIA_QUERY = '(prefers-color-scheme: dark)';
7
+ const THEMES = ['light', 'dark'];
5
8
  function narrow(value) {
6
- if (value === 'light' || value === 'dark' || value === 'system')
7
- return value;
8
- return 'system';
9
- }
10
- function narrowResolved(value) {
11
- return value === 'dark' ? 'dark' : 'light';
12
- }
13
- function NextThemesBridge({ children }) {
14
- const { theme, setTheme, resolvedTheme } = useNextTheme();
15
- const value = {
16
- theme: narrow(theme),
17
- setTheme: (t) => setTheme(t),
18
- resolvedTheme: narrowResolved(resolvedTheme),
9
+ return value === 'light' || value === 'dark' || value === 'system' ? value : 'system';
10
+ }
11
+ function getMediaQueryList() {
12
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
13
+ return undefined;
14
+ return window.matchMedia(MEDIA_QUERY);
15
+ }
16
+ function resolveSystemTheme(mql) {
17
+ if (mql)
18
+ return mql.matches ? 'dark' : 'light';
19
+ return getMediaQueryList()?.matches ? 'dark' : 'light';
20
+ }
21
+ function getStoredTheme() {
22
+ if (typeof window === 'undefined')
23
+ return 'system';
24
+ try {
25
+ return narrow(window.localStorage.getItem(STORAGE_KEY));
26
+ }
27
+ catch {
28
+ return 'system';
29
+ }
30
+ }
31
+ function applyTheme(theme, resolvedTheme) {
32
+ if (typeof document === 'undefined')
33
+ return;
34
+ const root = document.documentElement;
35
+ const applied = theme === 'system' ? resolvedTheme : theme;
36
+ root.classList.remove(...THEMES);
37
+ root.classList.add(applied);
38
+ root.style.colorScheme = applied;
39
+ }
40
+ function getClientThemeSnapshot() {
41
+ const theme = getStoredTheme();
42
+ return {
43
+ theme,
44
+ resolvedTheme: theme === 'system' ? resolveSystemTheme() : theme,
19
45
  };
20
- return _jsx(ThemeCtx.Provider, { value: value, children: children });
46
+ }
47
+ function subscribeToSystemTheme(mql, listener) {
48
+ if (!mql)
49
+ return () => { };
50
+ if (typeof mql.addEventListener === 'function') {
51
+ mql.addEventListener('change', listener);
52
+ return () => mql.removeEventListener('change', listener);
53
+ }
54
+ if (typeof mql.addListener === 'function') {
55
+ mql.addListener(listener);
56
+ return () => mql.removeListener?.(listener);
57
+ }
58
+ return () => { };
21
59
  }
22
60
  export function NextThemesAdapter({ children }) {
23
- return (_jsx(NextThemesProvider, { attribute: "class", enableSystem: true, defaultTheme: "system", children: _jsx(NextThemesBridge, { children: children }) }));
61
+ const [theme, setThemeState] = useState('system');
62
+ const [resolvedTheme, setResolvedTheme] = useState('light');
63
+ const [hydrated, setHydrated] = useState(false);
64
+ const setTheme = useCallback((next) => {
65
+ setThemeState(next);
66
+ const resolved = next === 'system' ? resolveSystemTheme() : next;
67
+ setResolvedTheme(resolved);
68
+ applyTheme(next, resolved);
69
+ try {
70
+ if (typeof window !== 'undefined')
71
+ window.localStorage.setItem(STORAGE_KEY, next);
72
+ }
73
+ catch {
74
+ // Ignore unavailable storage, matching next-themes' best-effort behavior.
75
+ }
76
+ }, []);
77
+ useInsertionEffect(() => {
78
+ const snapshot = getClientThemeSnapshot();
79
+ applyTheme(snapshot.theme, snapshot.resolvedTheme);
80
+ }, []);
81
+ useEffect(() => {
82
+ const stored = getClientThemeSnapshot();
83
+ setThemeState(stored.theme);
84
+ setResolvedTheme(stored.resolvedTheme);
85
+ setHydrated(true);
86
+ applyTheme(stored.theme, stored.resolvedTheme);
87
+ }, []);
88
+ useEffect(() => {
89
+ if (!hydrated)
90
+ return;
91
+ const mql = getMediaQueryList();
92
+ const sync = (_event) => {
93
+ const resolved = theme === 'system' ? resolveSystemTheme(mql) : theme;
94
+ setResolvedTheme(resolved);
95
+ applyTheme(theme, resolved);
96
+ };
97
+ sync();
98
+ return subscribeToSystemTheme(mql, sync);
99
+ }, [hydrated, theme]);
100
+ useEffect(() => {
101
+ const handleStorage = (event) => {
102
+ if (event.key !== STORAGE_KEY)
103
+ return;
104
+ setThemeState(narrow(event.newValue));
105
+ };
106
+ window.addEventListener('storage', handleStorage);
107
+ return () => window.removeEventListener('storage', handleStorage);
108
+ }, []);
109
+ const value = useMemo(() => ({
110
+ theme,
111
+ setTheme,
112
+ resolvedTheme,
113
+ }), [theme, setTheme, resolvedTheme]);
114
+ return _jsx(ThemeCtx.Provider, { value: value, children: children });
24
115
  }
@@ -3,7 +3,23 @@ import type { AnchorHTMLAttributes, ReactNode } from 'react';
3
3
  export interface AppDefinition {
4
4
  id: string;
5
5
  label: string;
6
- icon: LucideIcon;
6
+ /**
7
+ * Either a Lucide-style icon component or a rendered React node such as a
8
+ * custom brand SVG.
9
+ */
10
+ icon: LucideIcon | ReactNode;
11
+ /** Optional route target for consumers that render app tabs as links. */
12
+ to?: string;
13
+ }
14
+ export interface AppTabRenderLinkArgs {
15
+ app: AppDefinition;
16
+ isActive: boolean;
17
+ className: string;
18
+ children: ReactNode;
19
+ linkProps: AnchorHTMLAttributes<HTMLAnchorElement>;
20
+ }
21
+ export interface AppShellAppTabsConfig {
22
+ renderLink?: (args: AppTabRenderLinkArgs) => ReactNode;
7
23
  }
8
24
  export interface WorkspaceDefinition {
9
25
  id: string;
@@ -93,6 +109,44 @@ export interface AppShellRightPanelConfig {
93
109
  panelViews: PanelView[];
94
110
  defaultView?: string;
95
111
  }
112
+ /**
113
+ * A single configurable item in the WorkspaceSwitcher dropdown.
114
+ * Either `href` (renders as a link) or `onClick` (renders as a button) MUST
115
+ * be provided; supplying both makes the item a button that fires `onClick`
116
+ * AND navigates to `href` afterwards.
117
+ */
118
+ interface WorkspaceMenuItemBase {
119
+ id: string;
120
+ label: ReactNode;
121
+ icon?: LucideIcon | ReactNode;
122
+ /** Insert a `<DropdownMenuSeparator />` immediately after this item. */
123
+ separatorAfter?: boolean;
124
+ /** Visual intent — `destructive` paints with the destructive token. */
125
+ variant?: 'default' | 'destructive';
126
+ }
127
+ export type WorkspaceMenuItem = WorkspaceMenuItemBase & ({
128
+ href: string;
129
+ onClick?: () => void;
130
+ } | {
131
+ onClick: () => void;
132
+ href?: string;
133
+ });
134
+ /**
135
+ * Workspace dropdown configuration for `<AppShell variant="workspace">`.
136
+ *
137
+ * When `menuItems` is provided, it REPLACES the package-default menu
138
+ * ("Manage workspaces", "Settings", "Profile", "Sign out"). The theme toggle
139
+ * is still inserted automatically by meda — between the items and the footer
140
+ * — so consumers don't have to reimplement theme cycling.
141
+ *
142
+ * When `menuItems` is omitted, the package defaults render unchanged (the
143
+ * existing 1.x behavior). This is fully additive and non-breaking.
144
+ */
145
+ export interface AppShellWorkspaceConfig {
146
+ menuItems?: WorkspaceMenuItem[];
147
+ /** Extra slot rendered after all items and the theme toggle. */
148
+ menuFooter?: ReactNode;
149
+ }
96
150
  export interface AppShellAuthConfig {
97
151
  title: ReactNode;
98
152
  description?: ReactNode;
@@ -108,3 +162,4 @@ export interface AppShellAuthBranding {
108
162
  appName?: ReactNode;
109
163
  tagline?: ReactNode;
110
164
  }
165
+ export {};
@@ -1,7 +1,34 @@
1
1
  @import "./tokens.css";
2
2
 
3
+ /* Tell Tailwind v4 to scan meda's compiled component output so utilities
4
+ like `h-full`, `mt-auto`, `py-3.5`, and `bg-shell-rail` (used by IconRail,
5
+ AppShellWorkspace, etc.) are generated even when the consumer app doesn't
6
+ reference them itself. Without this, consumers see broken layout where
7
+ meda components rely on classes the app doesn't otherwise use. Path is
8
+ resolved relative to this CSS file at build time, so it points to the
9
+ sibling component output regardless of where the package is installed. */
10
+ @source "../**/*.js";
11
+
3
12
  @custom-variant dark (&:is(.dark *, [data-theme="dark"] *));
4
13
 
14
+ @layer base {
15
+ :root {
16
+ color-scheme: light;
17
+ --auth-gradient-primary: var(--color-brand-500);
18
+ --auth-gradient-secondary: var(--color-brand-700);
19
+ --auth-gradient-base: var(--color-brand-800);
20
+ }
21
+
22
+ .dark,
23
+ [data-theme="dark"] {
24
+ color-scheme: dark;
25
+ }
26
+ }
27
+
28
+ [data-vaul-drawer][data-state="open"] {
29
+ will-change: auto;
30
+ }
31
+
5
32
  @theme inline {
6
33
  /* Color primitives — exposed as Tailwind color scales */
7
34
 
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ // Read theme.css via Node's runtime APIs without pulling in @types/node.
4
+ // Declared locally so the package-level tsconfig (which doesn't include
5
+ // node typings) still typechecks cleanly under jsdom Vitest runs.
6
+ declare const require: (id: string) => unknown;
7
+ declare const __dirname: string;
8
+
9
+ const fs = require('node:fs') as { readFileSync(p: string, enc: string): string };
10
+ const path = require('node:path') as { join(...parts: string[]): string };
11
+ const themeCss = fs.readFileSync(path.join(__dirname, 'theme.css'), 'utf8');
12
+
13
+ describe('theme.css', () => {
14
+ // Tailwind v4 only generates utility classes for class names it sees in
15
+ // scanned files. Consumer apps don't reference every utility used by meda
16
+ // components (e.g. `h-full`, `mt-auto`, `py-3.5`, `bg-shell-rail` on
17
+ // IconRail), so without an @source directive pointing at meda's compiled
18
+ // dist files, those classes silently disappear and component layouts
19
+ // collapse — most visibly, the IconRail's utility items fail to pin to
20
+ // the bottom of the viewport because `mt-auto` never gets generated.
21
+ //
22
+ // The directive is resolved relative to this CSS file at build time. Since
23
+ // src/styles/theme.css is copied verbatim to dist/styles/theme.css by the
24
+ // build script, `../**/*.js` from the dist location scans every emitted
25
+ // component module — exactly the surface consumers import from.
26
+ it('declares an @source directive that scans meda component output', () => {
27
+ expect(themeCss).toMatch(/@source\s+["']\.\.\/\*\*\/\*\.js["']/);
28
+ });
29
+
30
+ it('declares dedicated auth gradient tokens for consumer overrides', () => {
31
+ expect(themeCss).toContain('--auth-gradient-primary: var(--color-brand-500);');
32
+ expect(themeCss).toContain('--auth-gradient-secondary: var(--color-brand-700);');
33
+ expect(themeCss).toContain('--auth-gradient-base: var(--color-brand-800);');
34
+ });
35
+
36
+ it('neutralizes vaul drawer layer hints after mobile drawers are open', () => {
37
+ expect(themeCss).toMatch(
38
+ /\[data-vaul-drawer\]\[data-state=["']open["']\]\s*\{[^}]*will-change:\s*auto;/s
39
+ );
40
+ });
41
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medalsocial/meda",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Shared Meda UI shell and runtime package.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {