@medalsocial/meda 1.3.0 → 1.5.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.
Files changed (43) hide show
  1. package/README.md +63 -17
  2. package/dist/auth/auth-message.d.ts +10 -0
  3. package/dist/auth/auth-message.js +18 -0
  4. package/dist/auth/auth-provider-button.d.ts +14 -0
  5. package/dist/auth/auth-provider-button.js +26 -0
  6. package/dist/auth/auth-provider-list.d.ts +6 -0
  7. package/dist/auth/auth-provider-list.js +7 -0
  8. package/dist/auth/better-auth.d.ts +40 -0
  9. package/dist/auth/better-auth.js +92 -0
  10. package/dist/auth/index.d.ts +6 -0
  11. package/dist/auth/index.js +3 -0
  12. package/dist/auth/public.d.ts +6 -0
  13. package/dist/auth/public.js +3 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +2 -0
  16. package/dist/lib/render-element.d.ts +9 -0
  17. package/dist/lib/render-element.js +27 -0
  18. package/dist/recipes/next.d.ts +49 -0
  19. package/dist/recipes/next.js +128 -0
  20. package/dist/shell/app-shell-workspace.d.ts +3 -2
  21. package/dist/shell/app-shell-workspace.js +2 -2
  22. package/dist/shell/app-shell.d.ts +11 -2
  23. package/dist/shell/app-shell.js +14 -2
  24. package/dist/shell/context-rail.js +9 -3
  25. package/dist/shell/icon-rail.d.ts +2 -1
  26. package/dist/shell/icon-rail.js +8 -1
  27. package/dist/shell/index.d.ts +4 -1
  28. package/dist/shell/index.js +1 -1
  29. package/dist/shell/internal/mobile-drawers.js +9 -2
  30. package/dist/shell/primitives.d.ts +9 -0
  31. package/dist/shell/primitives.js +7 -0
  32. package/dist/shell/right-panel.d.ts +12 -1
  33. package/dist/shell/right-panel.js +17 -5
  34. package/dist/shell/shell-header.d.ts +25 -3
  35. package/dist/shell/shell-header.js +39 -4
  36. package/dist/shell/types.d.ts +47 -1
  37. package/dist/styles/theme.css +20 -0
  38. package/dist/styles/theme.test.ts +29 -0
  39. package/dist/theme/index.d.ts +1 -0
  40. package/dist/theme/index.js +1 -0
  41. package/dist/theme/theme-bridge.d.ts +21 -0
  42. package/dist/theme/theme-bridge.js +52 -0
  43. package/package.json +21 -1
@@ -1,17 +1,26 @@
1
1
  import type { ReactNode } from 'react';
2
- import type { AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig } from './types.js';
2
+ import type { AppShellAuthBranding, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellWorkspaceConfig } from './types.js';
3
3
  interface AppShellBaseProps {
4
4
  children: ReactNode;
5
5
  className?: string;
6
6
  }
7
7
  export type AppShellProps = AppShellBaseProps & ({
8
8
  variant: 'auth';
9
- auth: AppShellAuthConfig;
9
+ auth?: AppShellAuthConfig;
10
+ branding?: AppShellAuthBranding;
11
+ preview?: ReactNode;
12
+ actions?: ReactNode;
10
13
  } | {
11
14
  variant: 'workspace';
12
15
  iconRail?: AppShellIconRailConfig;
13
16
  contextRail?: AppShellContextRailConfig;
14
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;
15
24
  globalActions?: ReactNode;
16
25
  } | {
17
26
  variant: 'chat';
@@ -14,13 +14,25 @@ export function AppShell(props) {
14
14
  const wrapper = (content) => (_jsx("div", { "data-meda-app": activeAppId, "data-meda-workspace": workspace.id, "data-meda-variant": props.variant, className: cn(heightClass, 'bg-background text-foreground', props.className), children: content }));
15
15
  switch (props.variant) {
16
16
  case 'auth':
17
- return wrapper(_jsx(AppShellAuth, { ...props.auth, children: props.children }));
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, globalActions: props.globalActions, children: props.children }));
20
20
  case 'chat':
21
21
  return wrapper(_jsx(AppShellChat, { globalActions: props.globalActions, children: props.children }));
22
22
  }
23
23
  }
24
+ function resolveAuthConfig(props) {
25
+ const { auth, branding } = props;
26
+ return {
27
+ title: auth?.title ?? branding?.appName ?? branding?.brandName ?? 'Sign in',
28
+ description: auth?.description ?? branding?.tagline,
29
+ brandName: auth?.brandName ?? branding?.brandName,
30
+ brandMark: auth?.brandMark ?? branding?.brandMark,
31
+ eyebrow: auth?.eyebrow,
32
+ preview: auth?.preview ?? props.preview,
33
+ actions: auth?.actions ?? props.actions,
34
+ };
35
+ }
24
36
  export function AppShellBody({ children, className }) {
25
37
  return (_jsx("div", { className: cn('relative flex h-[calc(100vh-var(--shell-header-height))] overflow-hidden', className), children: children }));
26
38
  }
@@ -15,7 +15,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
15
15
  * once <AppShellBody> ships as a ResizableShell Group.
16
16
  */
17
17
  import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';
18
- import { useId, useRef, useState } from 'react';
18
+ import { Fragment, useId, useRef, useState } from 'react';
19
19
  import { cn } from '../lib/utils.js';
20
20
  import { useMedaShell } from './shell-provider.js';
21
21
  import { useShellViewport } from './use-shell-viewport.js';
@@ -120,9 +120,15 @@ export function ContextRail({ appId, module, hidden = false, collapsible = true,
120
120
  : 'text-muted-foreground hover:bg-accent hover:text-foreground');
121
121
  const IconComp = item.icon;
122
122
  const inner = (_jsxs(_Fragment, { children: [_jsx(IconComp, { size: 16, "aria-hidden": "true", className: "shrink-0" }), _jsx("span", { className: "truncate", children: item.label }), item.shortcut && (_jsx("kbd", { className: "ml-auto font-mono text-[10px] text-muted-foreground", children: item.shortcut }))] }));
123
+ const linkProps = {
124
+ href: item.to,
125
+ 'aria-current': isActive ? 'page' : undefined,
126
+ className: klass,
127
+ children: inner,
128
+ };
123
129
  if (renderLink) {
124
- return renderLink({ item, isActive, className: klass, children: inner });
130
+ return (_jsx(Fragment, { children: renderLink({ item, isActive, className: klass, children: inner, linkProps }) }, item.id));
125
131
  }
126
- return (_jsx("a", { href: item.to, "aria-current": isActive ? 'page' : undefined, className: klass, children: inner }, item.id));
132
+ return _jsx("a", { ...linkProps }, item.id);
127
133
  }) })), module.render?.({ workspaceId: ctx.workspace.id, appId })] }), !collapsed && (_jsx(ResizeHandle, { currentWidth: width, onResize: handleResize, onCommit: handleCommit }))] }));
128
134
  }
@@ -1,5 +1,5 @@
1
1
  import type { LucideIcon } from 'lucide-react';
2
- import type { ReactNode } from 'react';
2
+ import type { AnchorHTMLAttributes, ReactNode } from 'react';
3
3
  export interface IconRailItem {
4
4
  id: string;
5
5
  label: string;
@@ -12,6 +12,7 @@ export interface IconRailRenderLinkArgs {
12
12
  isActive: boolean;
13
13
  className: string;
14
14
  children: ReactNode;
15
+ linkProps: AnchorHTMLAttributes<HTMLAnchorElement>;
15
16
  }
16
17
  export interface IconRailProps {
17
18
  mainItems: IconRailItem[];
@@ -29,9 +29,16 @@ export function IconRail({ mainItems, utilityItems = [], footer, activeId, rende
29
29
  const klass = itemClass(isActive);
30
30
  const IconComp = item.icon;
31
31
  const inner = (_jsxs(_Fragment, { children: [_jsx(IconComp, { size: 22, "aria-hidden": "true" }), item.badge ? _jsx("span", { className: "absolute right-1 top-1", children: item.badge }) : null] }));
32
+ const linkProps = {
33
+ href: item.to,
34
+ 'aria-label': item.label,
35
+ 'aria-current': isActive ? 'page' : undefined,
36
+ className: 'contents',
37
+ children: inner,
38
+ };
32
39
  const linkContent = renderLink ? (
33
40
  // renderLink consumers receive the className so they can apply it themselves
34
- renderLink({ item, isActive, className: klass, children: inner })) : (_jsx("a", { href: item.to, "aria-label": item.label, "aria-current": isActive ? 'page' : undefined, className: "contents", children: inner }));
41
+ renderLink({ item, isActive, className: klass, children: inner, linkProps })) : (_jsx("a", { ...linkProps }));
35
42
  return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx("span", { "data-testid": `icon-rail-trigger-${item.id}`, className: klass, children: linkContent }) }), _jsx(TooltipContent, { side: "right", children: item.label })] }, item.id));
36
43
  };
37
44
  return (_jsx(TooltipProvider, { children: _jsxs("nav", { "data-testid": "icon-rail", "aria-label": "Primary", className: cn('flex h-full w-[var(--shell-rail-width)] shrink-0 flex-col items-center bg-shell-rail py-3.5', className), children: [_jsx("div", { className: "flex flex-col items-center gap-1", children: mainItems.map(renderItem) }), utilityItems.length > 0 && (_jsxs(_Fragment, { children: [_jsx(RailDivider, { pinnedBottom: pinnedBottom, onToggle: () => setPinnedBottom((prev) => !prev) }), _jsx("div", { "data-testid": "utility-items-wrapper", className: cn('flex flex-col items-center gap-1', pinnedBottom && 'mt-auto'), children: utilityItems.map(renderItem) })] })), footer && (_jsx("div", { className: cn('pt-3', pinnedBottom || utilityItems.length === 0 ? 'mt-auto' : ''), children: footer }))] }) }));
@@ -1,3 +1,5 @@
1
+ export type { AuthMessageProps, AuthOneTapSlotProps, AuthProvider, AuthProviderButtonProps, AuthProviderListProps, } from '../auth/index.js';
2
+ export { AuthError, AuthNotice, AuthOneTapSlot, AuthProviderButton, AuthProviderList, } from '../auth/index.js';
1
3
  export { AppShell, AppShellBody } from './app-shell.js';
2
4
  export type { CommandGroupDefinition } from './command-palette.js';
3
5
  export { CommandPalette, useCommandGroup, useCommands } from './command-palette.js';
@@ -5,6 +7,7 @@ export { ContextRail } from './context-rail.js';
5
7
  export type { DragModeBannerProps } from './drag-mode-banner.js';
6
8
  export { DragModeBanner } from './drag-mode-banner.js';
7
9
  export * as Extras from './extras/index.js';
10
+ export type { IconRailItem, IconRailProps, IconRailRenderLinkArgs } from './icon-rail.js';
8
11
  export { IconRail, RailDivider } from './icon-rail.js';
9
12
  export type { ShellStorageAdapter } from './layout-state.js';
10
13
  export { createLocalStorageAdapter } from './layout-state.js';
@@ -23,5 +26,5 @@ export type { MedaShellProviderProps } from './shell-provider.js';
23
26
  export { MedaShellProvider, useMedaShell, useShellSelection } from './shell-provider.js';
24
27
  export { DefaultThemeProvider, ThemeToggle, useTheme } from './theme.js';
25
28
  export { NextThemesAdapter } from './theme-next-themes.js';
26
- export type { AppDefinition, AppShellAuthConfig, AppShellContextRailConfig, AppShellIconRailConfig, AppShellRightPanelConfig, AppShellVariant, CommandDefinition, ContextItem, ContextModule, MobileBottomNavItem, PanelMode, PanelView, ShellLinkRenderArgs, ShellMainLayout, ShellRenderContext, ShellViewport, ThemeAdapter, WorkspaceDefinition, } from './types.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';
27
30
  export { useShellViewport } from './use-shell-viewport.js';
@@ -3,6 +3,7 @@
3
3
  // the directive (see test/nextjs-consumer.test.ts). Each underlying component
4
4
  // file carries its own 'use client' — Next traces through the barrel and
5
5
  // applies them per-component. The barrel itself is just a re-exporter.
6
+ export { AuthError, AuthNotice, AuthOneTapSlot, AuthProviderButton, AuthProviderList, } from '../auth/index.js';
6
7
  // Layout
7
8
  export { AppShell, AppShellBody } from './app-shell.js';
8
9
  // Command palette
@@ -12,7 +13,6 @@ export { ContextRail } from './context-rail.js';
12
13
  export { DragModeBanner } from './drag-mode-banner.js';
13
14
  // Extras (legacy components ported during Phase 15 — opt-in for apps that need them)
14
15
  export * as Extras from './extras/index.js';
15
- // Rails + main + panel
16
16
  export { IconRail, RailDivider } from './icon-rail.js';
17
17
  // Storage adapter (consumers may want to provide their own)
18
18
  export { createLocalStorageAdapter } from './layout-state.js';
@@ -34,15 +34,22 @@ const menuItemClassName = 'flex items-center gap-2 rounded-md px-3 py-2 text-sm
34
34
  function MenuDrawerItem({ item, isActive, onClose, renderLink, }) {
35
35
  const Icon = item.icon;
36
36
  const children = (_jsxs(_Fragment, { children: [_jsx(Icon, { size: 18, "aria-hidden": "true" }), _jsx("span", { children: item.label })] }));
37
+ const className = cn(menuItemClassName, isActive && 'bg-accent text-foreground');
38
+ const linkProps = {
39
+ href: item.to,
40
+ className,
41
+ children,
42
+ };
37
43
  if (renderLink) {
38
44
  return closeAfterLinkClick(renderLink({
39
45
  item,
40
46
  isActive,
41
- className: cn(menuItemClassName, isActive && 'bg-accent text-foreground'),
47
+ className,
42
48
  children,
49
+ linkProps,
43
50
  }), onClose);
44
51
  }
45
- return (_jsx("a", { href: item.to, onClick: onClose, className: menuItemClassName, children: children }));
52
+ return closeAfterLinkClick(_jsx("a", { ...linkProps }), onClose);
46
53
  }
47
54
  function closeAfterLinkClick(link, onClose) {
48
55
  if (!isValidElement(link)) {
@@ -0,0 +1,9 @@
1
+ export type { ShellStorageAdapter } from './layout-state.js';
2
+ export { createLocalStorageAdapter } from './layout-state.js';
3
+ export type { PanelViewsProviderProps } from './panel-views-provider.js';
4
+ export { PanelViewsProvider } from './panel-views-provider.js';
5
+ export { ResizableHandle, ResizableShell, ResizableShellPanel } from './resizable-shell.js';
6
+ export type { MedaShellProviderProps } from './shell-provider.js';
7
+ export { MedaShellProvider, useMedaShell, useShellSelection } from './shell-provider.js';
8
+ export type { AppDefinition, CommandDefinition, ContextItem, ContextModule, MobileBottomNavItem, PanelMode, PanelView, ShellLinkRenderArgs, ShellRenderContext, ShellViewport, ThemeAdapter, WorkspaceDefinition, } from './types.js';
9
+ export { useShellViewport } from './use-shell-viewport.js';
@@ -0,0 +1,7 @@
1
+ // State and layout primitives for consumers that want Meda shell behavior
2
+ // without importing the full styled shell barrel.
3
+ export { createLocalStorageAdapter } from './layout-state.js';
4
+ export { PanelViewsProvider } from './panel-views-provider.js';
5
+ export { ResizableHandle, ResizableShell, ResizableShellPanel } from './resizable-shell.js';
6
+ export { MedaShellProvider, useMedaShell, useShellSelection } from './shell-provider.js';
7
+ export { useShellViewport } from './use-shell-viewport.js';
@@ -1,3 +1,4 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from 'react';
1
2
  import type { PanelMode, PanelView } from './types.js';
2
3
  export interface RightPanelProps {
3
4
  /** Views to render as tabs in the panel header. */
@@ -10,6 +11,16 @@ export interface RightPanelProps {
10
11
  * If only ['panel'], the cycle button is hidden.
11
12
  */
12
13
  modes?: PanelMode[];
14
+ renderTab?: (args: RightPanelTabRenderArgs) => ReactNode;
13
15
  className?: string;
14
16
  }
15
- export declare function RightPanel({ panelViews, defaultView, modes, className, }: RightPanelProps): import("react/jsx-runtime").JSX.Element | null;
17
+ export interface RightPanelTabRenderArgs {
18
+ view: PanelView;
19
+ isActive: boolean;
20
+ buttonProps: RightPanelTabButtonProps;
21
+ children: ReactNode;
22
+ }
23
+ export type RightPanelTabButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
24
+ 'data-active'?: boolean;
25
+ };
26
+ export declare function RightPanel({ panelViews, defaultView, modes, renderTab, className, }: RightPanelProps): import("react/jsx-runtime").JSX.Element | null;
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  /**
4
4
  * RightPanel — spec §12
5
5
  *
@@ -16,7 +16,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
16
16
  * TODO(phase-15-refactor): swap to ResizableShell once AppShellBody is a PanelGroup
17
17
  */
18
18
  import { Maximize2, Minimize2, X } from 'lucide-react';
19
- import { useEffect, useRef, useState } from 'react';
19
+ import { Fragment, useEffect, useRef, useState } from 'react';
20
20
  import { cn } from '../lib/utils.js';
21
21
  import { useResolvedPanelViews } from './panel-views-provider.js';
22
22
  import { useMedaShell } from './shell-provider.js';
@@ -63,7 +63,7 @@ function ResizeHandle({ currentWidth, onResize, onCommit }) {
63
63
  // ---------------------------------------------------------------------------
64
64
  // RightPanel
65
65
  // ---------------------------------------------------------------------------
66
- export function RightPanel({ panelViews = EMPTY_PANEL_VIEWS, defaultView, modes = ['panel', 'expanded', 'fullscreen'], className, }) {
66
+ export function RightPanel({ panelViews = EMPTY_PANEL_VIEWS, defaultView, modes = ['panel', 'expanded', 'fullscreen'], renderTab, className, }) {
67
67
  const band = useShellViewport();
68
68
  const ctx = useMedaShell();
69
69
  const { mode, activeView, width, setMode, setActiveView, setWidth } = ctx.panel;
@@ -132,8 +132,20 @@ export function RightPanel({ panelViews = EMPTY_PANEL_VIEWS, defaultView, modes
132
132
  return (_jsx("aside", { "data-meda-panel-mode": mode, "aria-hidden": mode === 'closed' ? 'true' : undefined, className: cn('relative h-full shrink-0 overflow-hidden border-l border-shell-border bg-shell-panel', 'transition-[width] ease-[var(--motion-ease)] duration-[var(--motion-panel)]', mode === 'fullscreen' && 'fixed inset-0 h-screen w-screen border-none', zIndexClass, className), style: widthStyle, children: mode !== 'closed' && (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-shell-border px-3 py-2", children: [_jsx("div", { className: "flex items-center gap-1", children: resolvedPanelViews.map((view) => {
133
133
  const isActive = view.id === activeView;
134
134
  const Icon = view.icon;
135
- return (_jsxs("button", { type: "button", "aria-current": isActive ? 'true' : undefined, onClick: () => setActiveView(view.id), className: cn('inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors', isActive
135
+ const children = (_jsxs(_Fragment, { children: [_jsx(Icon, { size: 14, "aria-hidden": "true" }), _jsx("span", { children: view.label })] }));
136
+ const buttonProps = {
137
+ type: 'button',
138
+ 'aria-current': isActive ? 'true' : undefined,
139
+ 'data-active': isActive || undefined,
140
+ onClick: () => setActiveView(view.id),
141
+ className: cn('inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors', isActive
136
142
  ? 'bg-accent text-accent-foreground'
137
- : 'text-muted-foreground hover:bg-accent hover:text-foreground'), children: [_jsx(Icon, { size: 14, "aria-hidden": "true" }), _jsx("span", { children: view.label })] }, view.id));
143
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'),
144
+ children,
145
+ };
146
+ if (renderTab) {
147
+ return (_jsx(Fragment, { children: renderTab({ view, isActive, buttonProps, children }) }, view.id));
148
+ }
149
+ return _jsx("button", { ...buttonProps }, view.id);
138
150
  }) }), _jsxs("div", { className: "flex items-center gap-1", children: [modes.length > 1 && (_jsx("button", { type: "button", "aria-label": cycleAriaLabel, onClick: cycleOpenMode, className: "inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground", children: mode === 'fullscreen' ? (_jsx(Minimize2, { size: 14, "aria-hidden": "true" })) : (_jsx(Maximize2, { size: 14, "aria-hidden": "true" })) })), _jsx("button", { type: "button", "aria-label": "Close panel", onClick: () => setMode('closed'), className: "inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground", children: _jsx(X, { size: 14, "aria-hidden": "true" }) })] })] }), _jsx("div", { className: "flex-1 overflow-y-auto", children: activePanelView != null ? (activePanelView.render(renderCtx)) : (_jsx("div", { className: "p-4 text-muted-foreground text-sm", children: "No panel view selected" })) }), mode === 'panel' && (_jsx(ResizeHandle, { currentWidth: resolvedWidth, onResize: handleResize, onCommit: handleCommit }))] })) }));
139
151
  }
@@ -1,12 +1,34 @@
1
- import type { ReactNode } from 'react';
1
+ import { type ReactNode } from 'react';
2
+ import type { 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;
21
+ export declare function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter, }?: WorkspaceSwitcherProps): import("react/jsx-runtime").JSX.Element;
6
22
  export declare function AppTabs(): import("react/jsx-runtime").JSX.Element;
7
23
  export declare function PanelToggle(): import("react/jsx-runtime").JSX.Element;
8
24
  export interface ShellHeaderProps {
9
25
  globalActions?: ReactNode;
10
26
  className?: string;
27
+ /**
28
+ * Forwarded to the internal `<WorkspaceSwitcher>`. See
29
+ * `WorkspaceSwitcherProps` for the full shape.
30
+ */
31
+ workspaceMenuItems?: WorkspaceMenuItem[];
32
+ workspaceMenuFooter?: ReactNode;
11
33
  }
12
- export declare function ShellHeader({ globalActions, className }?: ShellHeaderProps): import("react/jsx-runtime").JSX.Element | null;
34
+ export declare function ShellHeader({ globalActions, 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,9 +19,43 @@ 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 renderConfiguredIcon(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: [renderConfiguredIcon(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
60
  // ---------------------------------------------------------------------------
26
61
  // AppTabs
@@ -50,9 +85,9 @@ export function PanelToggle() {
50
85
  };
51
86
  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
87
  }
53
- export function ShellHeader({ globalActions, className } = {}) {
88
+ export function ShellHeader({ globalActions, className, workspaceMenuItems, workspaceMenuFooter, } = {}) {
54
89
  const band = useShellViewport();
55
90
  if (band === 'mobile')
56
91
  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, {})] })] }));
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, {})] })] }));
58
93
  }
@@ -1,5 +1,5 @@
1
1
  import type { LucideIcon } from 'lucide-react';
2
- import type { ReactNode } from 'react';
2
+ import type { AnchorHTMLAttributes, ReactNode } from 'react';
3
3
  export interface AppDefinition {
4
4
  id: string;
5
5
  label: string;
@@ -49,6 +49,7 @@ export interface ShellLinkRenderArgs {
49
49
  isActive: boolean;
50
50
  className: string;
51
51
  children: ReactNode;
52
+ linkProps: AnchorHTMLAttributes<HTMLAnchorElement>;
52
53
  }
53
54
  export interface CommandDefinition {
54
55
  id: string;
@@ -92,6 +93,44 @@ export interface AppShellRightPanelConfig {
92
93
  panelViews: PanelView[];
93
94
  defaultView?: string;
94
95
  }
96
+ /**
97
+ * A single configurable item in the WorkspaceSwitcher dropdown.
98
+ * Either `href` (renders as a link) or `onClick` (renders as a button) MUST
99
+ * be provided; supplying both makes the item a button that fires `onClick`
100
+ * AND navigates to `href` afterwards.
101
+ */
102
+ interface WorkspaceMenuItemBase {
103
+ id: string;
104
+ label: ReactNode;
105
+ icon?: LucideIcon | ReactNode;
106
+ /** Insert a `<DropdownMenuSeparator />` immediately after this item. */
107
+ separatorAfter?: boolean;
108
+ /** Visual intent — `destructive` paints with the destructive token. */
109
+ variant?: 'default' | 'destructive';
110
+ }
111
+ export type WorkspaceMenuItem = WorkspaceMenuItemBase & ({
112
+ href: string;
113
+ onClick?: () => void;
114
+ } | {
115
+ onClick: () => void;
116
+ href?: string;
117
+ });
118
+ /**
119
+ * Workspace dropdown configuration for `<AppShell variant="workspace">`.
120
+ *
121
+ * When `menuItems` is provided, it REPLACES the package-default menu
122
+ * ("Manage workspaces", "Settings", "Profile", "Sign out"). The theme toggle
123
+ * is still inserted automatically by meda — between the items and the footer
124
+ * — so consumers don't have to reimplement theme cycling.
125
+ *
126
+ * When `menuItems` is omitted, the package defaults render unchanged (the
127
+ * existing 1.x behavior). This is fully additive and non-breaking.
128
+ */
129
+ export interface AppShellWorkspaceConfig {
130
+ menuItems?: WorkspaceMenuItem[];
131
+ /** Extra slot rendered after all items and the theme toggle. */
132
+ menuFooter?: ReactNode;
133
+ }
95
134
  export interface AppShellAuthConfig {
96
135
  title: ReactNode;
97
136
  description?: ReactNode;
@@ -101,3 +140,10 @@ export interface AppShellAuthConfig {
101
140
  preview?: ReactNode;
102
141
  actions?: ReactNode;
103
142
  }
143
+ export interface AppShellAuthBranding {
144
+ brandName?: ReactNode;
145
+ brandMark?: ReactNode;
146
+ appName?: ReactNode;
147
+ tagline?: ReactNode;
148
+ }
149
+ export {};
@@ -1,7 +1,27 @@
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
+ }
18
+
19
+ .dark,
20
+ [data-theme="dark"] {
21
+ color-scheme: dark;
22
+ }
23
+ }
24
+
5
25
  @theme inline {
6
26
  /* Color primitives — exposed as Tailwind color scales */
7
27
 
@@ -0,0 +1,29 @@
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
+ });
@@ -0,0 +1 @@
1
+ export { createMedaThemeCss, defineMedaTheme, type MedaThemeConfig, type MedaThemeDefinition, type MedaThemeMode, type MedaThemeModeConfig, type MedaThemeTokenMap, } from './theme-bridge.js';
@@ -0,0 +1 @@
1
+ export { createMedaThemeCss, defineMedaTheme, } from './theme-bridge.js';
@@ -0,0 +1,21 @@
1
+ export type MedaThemeMode = 'light' | 'dark';
2
+ export type MedaThemeTokenMap = Record<string, string | undefined>;
3
+ export interface MedaThemeModeConfig {
4
+ colors?: MedaThemeTokenMap;
5
+ fonts?: MedaThemeTokenMap;
6
+ tokens?: MedaThemeTokenMap;
7
+ }
8
+ export interface MedaThemeConfig extends MedaThemeModeConfig {
9
+ appId: string;
10
+ selector?: string;
11
+ light?: MedaThemeModeConfig;
12
+ dark?: MedaThemeModeConfig;
13
+ }
14
+ export interface MedaThemeDefinition {
15
+ appId: string;
16
+ selector: string;
17
+ light: Required<MedaThemeModeConfig>;
18
+ dark: Required<MedaThemeModeConfig>;
19
+ }
20
+ export declare function defineMedaTheme(config: MedaThemeConfig): MedaThemeDefinition;
21
+ export declare function createMedaThemeCss(theme: MedaThemeDefinition): string;
@@ -0,0 +1,52 @@
1
+ const MODE_KEYS = ['colors', 'fonts', 'tokens'];
2
+ function assertAppId(appId) {
3
+ if (!/^[a-zA-Z0-9_-]+$/.test(appId)) {
4
+ throw new Error('Meda theme appId must contain only letters, numbers, underscores, or dashes.');
5
+ }
6
+ }
7
+ function toCssVariableName(group, key) {
8
+ const normalized = key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
9
+ if (normalized.startsWith('--')) {
10
+ return normalized;
11
+ }
12
+ if (group === 'fonts') {
13
+ return `--font-${normalized}`;
14
+ }
15
+ return `--${normalized}`;
16
+ }
17
+ function mergeModeConfig(base, mode = {}) {
18
+ return {
19
+ colors: { ...base.colors, ...mode.colors },
20
+ fonts: { ...base.fonts, ...mode.fonts },
21
+ tokens: { ...base.tokens, ...mode.tokens },
22
+ };
23
+ }
24
+ export function defineMedaTheme(config) {
25
+ assertAppId(config.appId);
26
+ const base = {
27
+ colors: config.colors ?? {},
28
+ fonts: config.fonts ?? {},
29
+ tokens: config.tokens ?? {},
30
+ };
31
+ return {
32
+ appId: config.appId,
33
+ selector: config.selector ?? `[data-meda-app="${config.appId}"]`,
34
+ light: mergeModeConfig(base, config.light),
35
+ dark: mergeModeConfig(base, config.dark),
36
+ };
37
+ }
38
+ function entriesForMode(mode) {
39
+ return MODE_KEYS.flatMap((group) => Object.entries(mode[group])
40
+ .filter((entry) => typeof entry[1] === 'string')
41
+ .map(([key, value]) => [toCssVariableName(group, key), value])).sort(([left], [right]) => left.localeCompare(right));
42
+ }
43
+ function renderBlock(selector, entries) {
44
+ const declarations = entries.map(([key, value]) => ` ${key}: ${value};`).join('\n');
45
+ return `${selector} {\n${declarations}\n}`;
46
+ }
47
+ export function createMedaThemeCss(theme) {
48
+ const lightEntries = entriesForMode(theme.light);
49
+ const darkEntries = entriesForMode(theme.dark);
50
+ const darkSelector = `${theme.selector}.dark, ${theme.selector}[data-theme="dark"]`;
51
+ return [renderBlock(theme.selector, lightEntries), renderBlock(darkSelector, darkEntries)].join('\n\n');
52
+ }