@medalsocial/meda 1.5.0 → 1.7.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,53 @@ 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
+
112
+ ## Foundation primitives
113
+
114
+ Meda includes small foundation primitives for repeated loading, empty, and filtering surfaces:
115
+
116
+ ```tsx
117
+ import { EmptyState, FilterRail, Skeleton } from '@medalsocial/meda';
118
+ ```
119
+
120
+ `Skeleton` mirrors shadcn's simple loading placeholder shape. `EmptyState` standardizes zero/error states across panels and content areas. `FilterRail` provides a dense filter surface while leaving selected values, URL syncing, and query logic in the consuming app.
121
+
122
+ The same primitives are available from the shadcn-compatible registry when an app wants local source ownership:
123
+
124
+ ```bash
125
+ npx shadcn add https://meda.medalsocial.com/r/meda-skeleton.json
126
+ npx shadcn add https://meda.medalsocial.com/r/meda-empty-state.json
127
+ npx shadcn add https://meda.medalsocial.com/r/meda-filter-rail.json
128
+ ```
129
+
66
130
  For app-scoped brand tokens:
67
131
 
68
132
  ```ts
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export * from './brand/public.js';
3
3
  export * from './chat/public.js';
4
4
  export * from './marketing/public.js';
5
5
  export * from './panel/public.js';
6
+ export * from './primitives/index.js';
6
7
  export * from './shell/index.js';
7
8
  export * from './theme/index.js';
8
9
  export * from './timeline/public.js';
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ export * from './brand/public.js';
3
3
  export * from './chat/public.js';
4
4
  export * from './marketing/public.js';
5
5
  export * from './panel/public.js';
6
+ export * from './primitives/index.js';
6
7
  export * from './shell/index.js'; // v2 surface
7
8
  export * from './theme/index.js';
8
9
  export * from './timeline/public.js';
@@ -0,0 +1,11 @@
1
+ import type { LucideIcon } from 'lucide-react';
2
+ import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
3
+ export type EmptyStateVariant = 'default' | 'panel' | 'inline';
4
+ export interface EmptyStateProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title'> {
5
+ icon?: LucideIcon | ReactNode;
6
+ title: ReactNode;
7
+ description?: ReactNode;
8
+ action?: ReactNode;
9
+ variant?: EmptyStateVariant;
10
+ }
11
+ export declare function EmptyState({ icon, title, description, action, variant, className, ...props }: EmptyStateProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { isValidElement } from 'react';
4
+ import { cn } from '../lib/utils.js';
5
+ function isIconComponent(icon) {
6
+ if (typeof icon === 'function')
7
+ return true;
8
+ if (typeof icon !== 'object')
9
+ return false;
10
+ return '$$typeof' in icon && !isValidElement(icon);
11
+ }
12
+ function renderIcon(icon) {
13
+ if (!icon)
14
+ return null;
15
+ if (isIconComponent(icon)) {
16
+ const Icon = icon;
17
+ return _jsx(Icon, { "data-testid": "empty-state-icon", className: "size-10", "aria-hidden": "true" });
18
+ }
19
+ return (_jsx("span", { "data-testid": "empty-state-icon", "aria-hidden": "true", className: "inline-flex", children: icon }));
20
+ }
21
+ export function EmptyState({ icon, title, description, action, variant = 'default', className, ...props }) {
22
+ return (_jsxs("div", { "data-slot": "empty-state", "data-variant": variant, className: cn('flex flex-col items-center justify-center text-center', variant === 'default' && 'px-6 py-16', variant === 'panel' && 'px-4 py-10', variant === 'inline' && 'px-3 py-6', className), ...props, children: [icon ? (_jsx("div", { "data-slot": "empty-state-icon", className: cn('mb-4 inline-flex items-center justify-center rounded-md text-muted-foreground', variant === 'inline' ? 'size-9' : 'size-12'), children: renderIcon(icon) })) : null, _jsx("h3", { "data-slot": "empty-state-title", className: cn('font-semibold text-foreground', variant === 'default' && 'text-lg', variant === 'panel' && 'text-base', variant === 'inline' && 'text-sm'), children: title }), description ? (_jsx("p", { "data-slot": "empty-state-description", className: cn('mt-1 max-w-sm text-muted-foreground', variant === 'inline' ? 'text-xs' : 'text-sm'), children: description })) : null, action ? (_jsx("div", { "data-slot": "empty-state-action", className: "mt-5", children: action })) : null] }));
23
+ }
@@ -0,0 +1,20 @@
1
+ import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
+ export interface FilterRailProps extends Omit<ComponentPropsWithoutRef<'aside'>, 'title'> {
3
+ title?: ReactNode;
4
+ description?: ReactNode;
5
+ search?: ReactNode;
6
+ actions?: ReactNode;
7
+ footer?: ReactNode;
8
+ children?: ReactNode;
9
+ }
10
+ export interface FilterRailGroupProps extends Omit<ComponentPropsWithoutRef<'fieldset'>, 'title'> {
11
+ title?: ReactNode;
12
+ description?: ReactNode;
13
+ children?: ReactNode;
14
+ }
15
+ declare function FilterRailGroup({ title, description, children, className, ...props }: FilterRailGroupProps): import("react/jsx-runtime").JSX.Element;
16
+ declare function FilterRailRoot({ title, description, search, actions, footer, children, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...props }: FilterRailProps): import("react/jsx-runtime").JSX.Element;
17
+ export declare const FilterRail: typeof FilterRailRoot & {
18
+ Group: typeof FilterRailGroup;
19
+ };
20
+ export {};
@@ -0,0 +1,16 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId } from 'react';
4
+ import { cn } from '../lib/utils.js';
5
+ function FilterRailGroup({ title, description, children, className, ...props }) {
6
+ return (_jsxs("fieldset", { "data-slot": "filter-rail-group", className: cn('space-y-2 border-0 p-0', className), ...props, children: [title ? (_jsx("legend", { "data-slot": "filter-rail-group-title", className: "text-xs font-semibold text-foreground", children: title })) : null, description ? (_jsx("p", { "data-slot": "filter-rail-group-description", className: "text-xs text-muted-foreground", children: description })) : null, _jsx("div", { "data-slot": "filter-rail-group-content", className: "space-y-1.5", children: children })] }));
7
+ }
8
+ function FilterRailRoot({ title, description, search, actions, footer, children, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...props }) {
9
+ const titleId = useId();
10
+ const label = ariaLabel ?? (ariaLabelledBy ? undefined : typeof title === 'string' ? title : undefined);
11
+ const labelledBy = ariaLabelledBy ?? (label ? undefined : title ? titleId : undefined);
12
+ return (_jsxs("aside", { "data-slot": "filter-rail", "aria-label": label, "aria-labelledby": labelledBy, className: cn('flex min-h-0 w-full flex-col border-border bg-card text-card-foreground', className), ...props, children: [title || description || actions ? (_jsxs("div", { "data-slot": "filter-rail-header", className: "flex shrink-0 items-start justify-between gap-3 border-b border-border px-4 py-3", children: [_jsxs("div", { className: "min-w-0", children: [title ? (_jsx("h2", { id: titleId, "data-slot": "filter-rail-title", className: "text-sm font-semibold text-foreground", children: title })) : null, description ? (_jsx("p", { "data-slot": "filter-rail-description", className: "mt-0.5 text-xs text-muted-foreground", children: description })) : null] }), actions ? (_jsx("div", { "data-slot": "filter-rail-actions", className: "shrink-0", children: actions })) : null] })) : null, search ? (_jsx("div", { "data-slot": "filter-rail-search", className: "shrink-0 border-b border-border p-3", children: search })) : null, _jsx("div", { "data-slot": "filter-rail-content", className: "min-h-0 flex-1 space-y-5 overflow-y-auto p-4", children: children }), footer ? (_jsx("div", { "data-slot": "filter-rail-footer", className: "shrink-0 border-t border-border p-3", children: footer })) : null] }));
13
+ }
14
+ export const FilterRail = Object.assign(FilterRailRoot, {
15
+ Group: FilterRailGroup,
16
+ });
@@ -0,0 +1,6 @@
1
+ export type { EmptyStateProps, EmptyStateVariant } from './empty-state.js';
2
+ export { EmptyState } from './empty-state.js';
3
+ export type { FilterRailGroupProps, FilterRailProps } from './filter-rail.js';
4
+ export { FilterRail } from './filter-rail.js';
5
+ export type { SkeletonProps } from './skeleton.js';
6
+ export { Skeleton } from './skeleton.js';
@@ -0,0 +1,3 @@
1
+ export { EmptyState } from './empty-state.js';
2
+ export { FilterRail } from './filter-rail.js';
3
+ export { Skeleton } from './skeleton.js';
@@ -0,0 +1,3 @@
1
+ import type { ComponentProps } from 'react';
2
+ export type SkeletonProps = ComponentProps<'div'>;
3
+ export declare function Skeleton({ className, 'aria-hidden': ariaHidden, ...props }: SkeletonProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,6 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { cn } from '../lib/utils.js';
4
+ export function Skeleton({ className, 'aria-hidden': ariaHidden = true, ...props }) {
5
+ return (_jsx("div", { "data-slot": "skeleton", "aria-hidden": ariaHidden, className: cn('animate-pulse rounded-md bg-muted', className), ...props }));
6
+ }
@@ -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,11 +1,16 @@
1
- import type { ReactNode } from 'react';
2
- import type { AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig } 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
7
  workspace?: AppShellWorkspaceConfig;
8
+ appTabs?: AppShellAppTabsConfig;
8
9
  globalActions?: ReactNode;
10
+ headerCenter?: ReactNode;
11
+ banners?: ReactNode;
12
+ mainLayout?: ShellMainLayout;
13
+ mainClassName?: string;
9
14
  children: ReactNode;
10
15
  }
11
- export declare function AppShellWorkspace({ iconRail, contextRail, rightPanel, workspace, 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, workspace, 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, workspace
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, workspaceMenuItems: workspace?.menuItems, workspaceMenuFooter: workspace?.menuFooter })), _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, AppShellWorkspaceConfig } 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;
@@ -21,7 +21,31 @@ export type AppShellProps = AppShellBaseProps & ({
21
21
  * `menuItems` is provided. The theme toggle is preserved automatically.
22
22
  */
23
23
  workspace?: AppShellWorkspaceConfig;
24
+ /**
25
+ * Optional application-tab rendering config, used for router-specific
26
+ * link integration.
27
+ */
28
+ appTabs?: AppShellAppTabsConfig;
24
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;
25
49
  } | {
26
50
  variant: 'chat';
27
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, workspace: props.workspace, 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, AppShellWorkspaceConfig, CommandDefinition, ContextItem, ContextModule, MobileBottomNavItem, PanelMode, PanelView, ShellLinkRenderArgs, ShellMainLayout, ShellRenderContext, ShellViewport, ThemeAdapter, WorkspaceDefinition, WorkspaceMenuItem, } 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,5 +1,5 @@
1
1
  import { type ReactNode } from 'react';
2
- import type { WorkspaceMenuItem } from './types.js';
2
+ import type { AppShellAppTabsConfig, WorkspaceMenuItem } from './types.js';
3
3
  export interface WorkspaceSwitcherProps {
4
4
  /**
5
5
  * Configurable dropdown items. When provided, REPLACES the default
@@ -19,10 +19,18 @@ export interface WorkspaceSwitcherProps {
19
19
  workspaceMenuFooter?: ReactNode;
20
20
  }
21
21
  export declare function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter, }?: WorkspaceSwitcherProps): import("react/jsx-runtime").JSX.Element;
22
- export declare function AppTabs(): 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;
23
25
  export declare function PanelToggle(): import("react/jsx-runtime").JSX.Element;
24
26
  export interface ShellHeaderProps {
25
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'];
26
34
  className?: string;
27
35
  /**
28
36
  * Forwarded to the internal `<WorkspaceSwitcher>`. See
@@ -31,4 +39,4 @@ export interface ShellHeaderProps {
31
39
  workspaceMenuItems?: WorkspaceMenuItem[];
32
40
  workspaceMenuFooter?: ReactNode;
33
41
  }
34
- export declare function ShellHeader({ globalActions, className, workspaceMenuItems, workspaceMenuFooter, }?: 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;
@@ -19,7 +19,7 @@ function ThemeToggleMenuItem() {
19
19
  const Icon = THEME_ICON[theme];
20
20
  return (_jsxs(DropdownMenuItem, { onClick: () => setTheme(NEXT_THEME[theme]), children: [_jsx(Icon, { size: 16, "aria-hidden": "true" }), THEME_LABEL[theme]] }));
21
21
  }
22
- function renderConfiguredIcon(icon) {
22
+ function renderShellIcon(icon) {
23
23
  if (icon == null)
24
24
  return null;
25
25
  if (isValidElement(icon))
@@ -49,7 +49,7 @@ function renderConfiguredItem(item) {
49
49
  // label children below and configured icons would silently disappear.
50
50
  // biome-ignore lint/a11y/useAnchorContent: children are injected at render time by Base UI's `render` prop
51
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: [renderConfiguredIcon(item.icon), item.label] }));
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
53
  }
54
54
  export function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter, } = {}) {
55
55
  const { workspace, workspaces } = useMedaShell();
@@ -57,21 +57,33 @@ export function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter,
57
57
  const useConfiguredItems = Array.isArray(menuItems);
58
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] })] }));
59
59
  }
60
- // ---------------------------------------------------------------------------
61
- // AppTabs
62
- // ---------------------------------------------------------------------------
63
- export function AppTabs() {
60
+ export function AppTabs({ renderLink } = {}) {
64
61
  const { apps, activeAppId, setActiveApp } = useMedaShell();
65
62
  return (_jsx("nav", { "aria-label": "Applications", className: "flex items-center", children: apps.map((app) => {
66
63
  const isActive = app.id === activeAppId;
67
- const Icon = app.icon;
68
- return (_jsxs("button", { type: "button", "aria-current": isActive ? 'page' : undefined, onClick: () => {
69
- setActiveApp(app.id);
70
- // TODO(Phase 18.x): renderLink integration so clicking a tab also navigates
71
- // the consumer's router; setActiveApp alone updates context.
72
- }, onMouseEnter: () => { }, className: cn('flex items-center gap-1.5 px-3 py-2 text-sm font-medium transition-colors', isActive
73
- ? 'border-b-2 border-primary text-foreground'
74
- : '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));
75
87
  }) }));
76
88
  }
77
89
  // ---------------------------------------------------------------------------
@@ -85,9 +97,9 @@ export function PanelToggle() {
85
97
  };
86
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" })) }));
87
99
  }
88
- export function ShellHeader({ globalActions, className, workspaceMenuItems, workspaceMenuFooter, } = {}) {
100
+ export function ShellHeader({ globalActions, headerCenter, appTabsRenderLink, className, workspaceMenuItems, workspaceMenuFooter, } = {}) {
89
101
  const band = useShellViewport();
90
102
  if (band === 'mobile')
91
103
  return null;
92
- 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, { menuItems: workspaceMenuItems, menuFooter: workspaceMenuFooter }), _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, {})] })] }));
93
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;
@@ -14,6 +14,9 @@
14
14
  @layer base {
15
15
  :root {
16
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);
17
20
  }
18
21
 
19
22
  .dark,
@@ -22,6 +25,10 @@
22
25
  }
23
26
  }
24
27
 
28
+ [data-vaul-drawer][data-state="open"] {
29
+ will-change: auto;
30
+ }
31
+
25
32
  @theme inline {
26
33
  /* Color primitives — exposed as Tailwind color scales */
27
34
 
@@ -26,4 +26,16 @@ describe('theme.css', () => {
26
26
  it('declares an @source directive that scans meda component output', () => {
27
27
  expect(themeCss).toMatch(/@source\s+["']\.\.\/\*\*\/\*\.js["']/);
28
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
+ });
29
41
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medalsocial/meda",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Shared Meda UI shell and runtime package.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {