@mounaji_npm/saas-template 0.1.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.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * ModuleRegistry — @mounaji/saas-template
3
+ *
4
+ * Central registry for navigation modules. Each page in the app registers
5
+ * a manifest here; the Sidebar reads the registry to build its nav tree.
6
+ *
7
+ * Two usage patterns:
8
+ *
9
+ * ── Static (recommended for most apps) ─────────────────────────────────────
10
+ * Pass modules directly to AppShell:
11
+ * <AppShell modules={[DASHBOARD_MODULE, CHAT_MODULE, MY_CUSTOM_MODULE]}>
12
+ * {children}
13
+ * </AppShell>
14
+ *
15
+ * ── Dynamic (for plugin-style architectures) ───────────────────────────────
16
+ * Call useRegisterModule() at the top of a page component:
17
+ * function MyPage() {
18
+ * useRegisterModule(MY_MODULE);
19
+ * return <div>...</div>;
20
+ * }
21
+ *
22
+ * ── Module Manifest Shape ────────────────────────────────────────────────────
23
+ * {
24
+ * id: string — unique identifier (e.g. 'dashboard')
25
+ * label: string — display name in sidebar
26
+ * icon: ReactNode — icon element or render function
27
+ * path: string — href for Link (e.g. '/dashboard')
28
+ * section: string|null — sidebar section group label (null = top section)
29
+ * order: number — sort order within section (lower = higher)
30
+ * children: Manifest[] — optional sub-nav items
31
+ * badge: string|null — badge text shown next to label (e.g. 'Beta')
32
+ * disabled: boolean — grayed out, not clickable
33
+ * hidden: boolean — completely omit from nav (useful for role-based hiding)
34
+ * external: boolean — open in new tab
35
+ * }
36
+ */
37
+
38
+ import { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react';
39
+
40
+ const RegistryContext = createContext({
41
+ modules: [],
42
+ register: () => {},
43
+ unregister: () => {},
44
+ });
45
+
46
+ export function ModuleRegistryProvider({ children, initialModules = [] }) {
47
+ const [dynamic, setDynamic] = useState([]);
48
+
49
+ const register = useCallback((manifest) => {
50
+ setDynamic(prev => {
51
+ const exists = prev.find(m => m.id === manifest.id);
52
+ if (exists) return prev.map(m => m.id === manifest.id ? manifest : m);
53
+ return [...prev, manifest];
54
+ });
55
+ }, []);
56
+
57
+ const unregister = useCallback((id) => {
58
+ setDynamic(prev => prev.filter(m => m.id !== id));
59
+ }, []);
60
+
61
+ // Merge initial (static) + dynamic modules, deduplicated by id
62
+ // Dynamic modules override static ones with the same id
63
+ const modules = useMemo(() => {
64
+ const map = new Map();
65
+ [...initialModules, ...dynamic].forEach(m => map.set(m.id, m));
66
+ return Array.from(map.values());
67
+ }, [initialModules, dynamic]);
68
+
69
+ return (
70
+ <RegistryContext.Provider value={{ modules, register, unregister }}>
71
+ {children}
72
+ </RegistryContext.Provider>
73
+ );
74
+ }
75
+
76
+ export function useModuleRegistry() {
77
+ return useContext(RegistryContext);
78
+ }
79
+
80
+ /**
81
+ * Dynamically register a module when a component mounts.
82
+ * Automatically unregisters when the component unmounts.
83
+ *
84
+ * @param {object} manifest
85
+ * @param {boolean} [enabled=true] — set false to skip registration (for conditional modules)
86
+ */
87
+ export function useRegisterModule(manifest, enabled = true) {
88
+ const { register, unregister } = useModuleRegistry();
89
+
90
+ useEffect(() => {
91
+ if (!enabled || !manifest?.id) return;
92
+ register(manifest);
93
+ return () => unregister(manifest.id);
94
+ // eslint-disable-next-line react-hooks/exhaustive-deps
95
+ }, [manifest?.id, enabled]);
96
+ }
97
+
98
+ /**
99
+ * Returns the nav tree sorted into sections, ready for Sidebar rendering.
100
+ *
101
+ * @returns {{ label: string|null, items: object[] }[]}
102
+ */
103
+ export function useNavTree() {
104
+ const { modules } = useModuleRegistry();
105
+
106
+ return useMemo(() => {
107
+ const visible = modules.filter(m => !m.hidden);
108
+ const sectionMap = new Map();
109
+
110
+ visible.forEach(m => {
111
+ const key = m.section ?? '__TOP__';
112
+ if (!sectionMap.has(key)) sectionMap.set(key, []);
113
+ sectionMap.get(key).push(m);
114
+ });
115
+
116
+ // Sort items within each section
117
+ sectionMap.forEach(items => items.sort((a, b) => (a.order ?? 99) - (b.order ?? 99)));
118
+
119
+ // Order sections: __TOP__ first, then alphabetical
120
+ const sections = [];
121
+ if (sectionMap.has('__TOP__')) {
122
+ sections.push({ label: null, items: sectionMap.get('__TOP__') });
123
+ }
124
+ Array.from(sectionMap.entries())
125
+ .filter(([k]) => k !== '__TOP__')
126
+ .sort(([a], [b]) => a.localeCompare(b))
127
+ .forEach(([label, items]) => sections.push({ label, items }));
128
+
129
+ return sections;
130
+ }, [modules]);
131
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * AppShell — @mounaji/saas-template
3
+ *
4
+ * Root layout compositor. Wires together:
5
+ * TokensProvider → design tokens injected to :root
6
+ * ModuleRegistry → nav module list available to Sidebar
7
+ * Sidebar → collapsible left nav
8
+ * TopNav → sticky top bar
9
+ * <children> → page content
10
+ *
11
+ * ── Minimal usage (React app, e.g. Vite) ───────────────────────────────────
12
+ * import { AppShell } from '@mounaji/saas-template';
13
+ * import { DASHBOARD_MODULE, CHAT_MODULE } from '@mounaji/saas-template/modules';
14
+ *
15
+ * <AppShell modules={[DASHBOARD_MODULE, CHAT_MODULE]} activePath={location.pathname}>
16
+ * <Route path="/dashboard" element={<Dashboard />} />
17
+ * </AppShell>
18
+ *
19
+ * ── Next.js App Router (app/layout.js) ─────────────────────────────────────
20
+ * 'use client'; // AppShell is a client component
21
+ * import { AppShell } from '@mounaji/saas-template';
22
+ * import { DASHBOARD_MODULE, CHAT_MODULE } from '@mounaji/saas-template/modules';
23
+ * import { usePathname } from 'next/navigation';
24
+ * import Link from 'next/link';
25
+ *
26
+ * export default function RootLayout({ children }) {
27
+ * return (
28
+ * <html><body>
29
+ * <AppShellWrapper>{children}</AppShellWrapper>
30
+ * </body></html>
31
+ * );
32
+ * }
33
+ *
34
+ * function AppShellWrapper({ children }) {
35
+ * const pathname = usePathname();
36
+ * return (
37
+ * <AppShell
38
+ * modules={[DASHBOARD_MODULE, CHAT_MODULE]}
39
+ * activePath={pathname}
40
+ * LinkComponent={Link} // pass Next.js Link for client-side nav
41
+ * >
42
+ * {children}
43
+ * </AppShell>
44
+ * );
45
+ * }
46
+ *
47
+ * ── Adding a custom page module ─────────────────────────────────────────────
48
+ * // my-module.js — export a manifest
49
+ * export const MY_MODULE = {
50
+ * id: 'analytics',
51
+ * label: 'Analytics',
52
+ * icon: '📊', // string emoji, or pass React element/function
53
+ * path: '/analytics',
54
+ * section: 'Workspace',
55
+ * order: 10,
56
+ * };
57
+ *
58
+ * // in AppShell modules prop:
59
+ * <AppShell modules={[...DEFAULT_MODULES, MY_MODULE]}>
60
+ *
61
+ * Props:
62
+ * modules — ModuleManifest[] (nav items)
63
+ * activePath — string (current route path)
64
+ * onNavigate — (path) => void (router push — omit to use href links)
65
+ * LinkComponent — React component (default: 'a'. Pass Next.js Link, React Router Link, etc.)
66
+ * logo — React node (sidebar brand logo)
67
+ * bottomModules — ModuleManifest[] (bottom nav items: Settings, Help)
68
+ * org — { name, logo? }
69
+ * project — { name }
70
+ * onOrgClick — () => void
71
+ * onProjectClick — () => void
72
+ * onNewProject — () => void
73
+ * user — { name, avatar?, email? }
74
+ * onLogout — () => void
75
+ * isDark — boolean (for theme toggle display)
76
+ * onThemeToggle — () => void
77
+ * topNavLeft — React node (override breadcrumb)
78
+ * topNavCenter — React node
79
+ * topNavRight — React node (appended to right side)
80
+ * tokens — Partial<DEFAULT_TOKENS>
81
+ * tokenPersist — boolean (persist tokens to localStorage, default: true)
82
+ * tokenStorageKey — string (default: 'mn_design_tokens')
83
+ * children — React node (page content)
84
+ */
85
+
86
+ import { useState } from 'react';
87
+ import { TokensProvider } from '@mounaji_npm/tokens';
88
+ import { ModuleRegistryProvider } from '../registry/ModuleRegistry.jsx';
89
+ import { Sidebar } from './Sidebar.jsx';
90
+ import { TopNav } from './TopNav.jsx';
91
+
92
+ const SIDEBAR_EXPANDED = 220;
93
+ const SIDEBAR_COLLAPSED = 60;
94
+
95
+ export function AppShell({
96
+ // Nav
97
+ modules = [],
98
+ bottomModules = DEFAULT_BOTTOM_MODULES,
99
+ activePath = '/',
100
+ onNavigate,
101
+ LinkComponent = 'a',
102
+ logo,
103
+
104
+ // TopNav
105
+ org,
106
+ project,
107
+ onOrgClick,
108
+ onProjectClick,
109
+ onNewProject,
110
+ user,
111
+ onLogout,
112
+ isDark = true,
113
+ onThemeToggle,
114
+ topNavLeft,
115
+ topNavCenter,
116
+ topNavRight,
117
+
118
+ // Tokens
119
+ tokens,
120
+ tokenPersist = true,
121
+ tokenStorageKey = 'mn_design_tokens',
122
+
123
+ // Content
124
+ children,
125
+ }) {
126
+ const [collapsed, setCollapsed] = useState(false);
127
+
128
+ const sidebarWidth = collapsed ? SIDEBAR_COLLAPSED : SIDEBAR_EXPANDED;
129
+
130
+ return (
131
+ <TokensProvider
132
+ initialTokens={tokens}
133
+ persist={tokenPersist}
134
+ storageKey={tokenStorageKey}
135
+ >
136
+ <ModuleRegistryProvider initialModules={modules}>
137
+ <div style={{ display: 'flex', minHeight: '100vh', backgroundColor: 'var(--mn-color-bg-dark, #060919)' }}>
138
+
139
+ {/* Sidebar */}
140
+ <Sidebar
141
+ logo={logo}
142
+ bottomItems={bottomModules}
143
+ collapsed={collapsed}
144
+ onCollapse={setCollapsed}
145
+ activePath={activePath}
146
+ onNavigate={onNavigate}
147
+ LinkComponent={LinkComponent}
148
+ />
149
+
150
+ {/* Main area — offset by sidebar width */}
151
+ <div style={{
152
+ flex: 1,
153
+ marginLeft: sidebarWidth,
154
+ transition: `margin-left 340ms cubic-bezier(0.16,1,0.3,1)`,
155
+ display: 'flex',
156
+ flexDirection: 'column',
157
+ minWidth: 0,
158
+ }}>
159
+ {/* Top Nav */}
160
+ <TopNav
161
+ org={org}
162
+ project={project}
163
+ onOrgClick={onOrgClick}
164
+ onProjectClick={onProjectClick}
165
+ onNewProject={onNewProject}
166
+ user={user}
167
+ onLogout={onLogout}
168
+ isDark={isDark}
169
+ onThemeToggle={onThemeToggle}
170
+ leftSlot={topNavLeft}
171
+ centerSlot={topNavCenter}
172
+ rightSlot={topNavRight}
173
+ />
174
+
175
+ {/* Page content */}
176
+ <main style={{ flex: 1, minHeight: 0 }}>
177
+ {children}
178
+ </main>
179
+ </div>
180
+
181
+ </div>
182
+ </ModuleRegistryProvider>
183
+ </TokensProvider>
184
+ );
185
+ }
186
+
187
+ // ─── Default bottom modules ───────────────────────────────────────────────────
188
+
189
+ export const DEFAULT_BOTTOM_MODULES = [
190
+ { id: 'settings', label: 'Settings', icon: '⚙', path: '/settings', order: 1 },
191
+ { id: 'help', label: 'Help', icon: '?', path: '/help', order: 2 },
192
+ ];
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Sidebar — @mounaji/saas-template
3
+ *
4
+ * Collapsible sidebar that renders nav sections from ModuleRegistry.
5
+ * All styles are token-driven via CSS variables.
6
+ *
7
+ * Props:
8
+ * logo — React node (brand logo/name)
9
+ * bottomItems — Manifest[] (always-visible bottom nav items like Settings, Help)
10
+ * collapsed — boolean (controlled)
11
+ * onCollapse — (collapsed: boolean) => void
12
+ * activePath — string (current pathname — compare to module.path)
13
+ * onNavigate — (path: string) => void (call router.push internally)
14
+ * LinkComponent — React component for links (default: <a>; pass Next.js <Link>)
15
+ * tokens — Partial<DEFAULT_TOKENS>
16
+ */
17
+
18
+ import { useState, useCallback } from 'react';
19
+ import { useNavTree } from '../registry/ModuleRegistry.jsx';
20
+ import { TOKEN_CSS_MAP } from '@mounaji_npm/tokens';
21
+
22
+ function buildInlineTokens(tokens) {
23
+ if (!tokens) return {};
24
+ const s = {};
25
+ Object.entries(tokens).forEach(([k, v]) => { const c = TOKEN_CSS_MAP[k]; if (c) s[c] = v; });
26
+ return s;
27
+ }
28
+
29
+ const SIDEBAR_EXPANDED_WIDTH = 220;
30
+ const SIDEBAR_COLLAPSED_WIDTH = 60;
31
+
32
+ export function Sidebar({
33
+ logo,
34
+ bottomItems = [],
35
+ collapsed = false,
36
+ onCollapse,
37
+ activePath = '/',
38
+ onNavigate,
39
+ LinkComponent = 'a',
40
+ tokens,
41
+ style,
42
+ }) {
43
+ const sections = useNavTree();
44
+ const [expandedIds, setExpandedIds] = useState(new Set());
45
+
46
+ const toggleExpand = useCallback((id) => {
47
+ setExpandedIds(prev => {
48
+ const next = new Set(prev);
49
+ next.has(id) ? next.delete(id) : next.add(id);
50
+ return next;
51
+ });
52
+ }, []);
53
+
54
+ function isActive(path) {
55
+ if (path === '/') return activePath === '/';
56
+ return activePath === path || activePath.startsWith(path + '/');
57
+ }
58
+
59
+ const width = collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH;
60
+
61
+ return (
62
+ <aside
63
+ style={{
64
+ position: 'fixed',
65
+ top: 0,
66
+ left: 0,
67
+ bottom: 0,
68
+ width,
69
+ display: 'flex',
70
+ flexDirection: 'column',
71
+ backgroundColor: 'var(--mn-color-nav-dark, #07091C)',
72
+ borderRight: '1px solid var(--mn-border-dark, rgba(255,255,255,0.07))',
73
+ transition: `width 340ms cubic-bezier(0.16,1,0.3,1)`,
74
+ overflow: 'hidden',
75
+ zIndex: 40,
76
+ fontFamily: 'var(--mn-font-family, inherit)',
77
+ ...buildInlineTokens(tokens),
78
+ ...style,
79
+ }}
80
+ >
81
+ {/* Brand / Logo */}
82
+ <div style={{
83
+ height: 56,
84
+ display: 'flex',
85
+ alignItems: 'center',
86
+ justifyContent: collapsed ? 'center' : 'space-between',
87
+ padding: collapsed ? '0' : '0 12px 0 16px',
88
+ borderBottom: '1px solid var(--mn-border-dark, rgba(255,255,255,0.07))',
89
+ flexShrink: 0,
90
+ gap: 8,
91
+ }}>
92
+ {!collapsed && (
93
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, overflow: 'hidden' }}>
94
+ {logo ?? <DefaultLogo />}
95
+ </div>
96
+ )}
97
+
98
+ <CollapseButton collapsed={collapsed} onCollapse={() => onCollapse?.(!collapsed)} />
99
+ </div>
100
+
101
+ {/* Nav sections — scrollable */}
102
+ <nav style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 0' }}>
103
+ {sections.map(section => (
104
+ <SidebarSection
105
+ key={section.label ?? '__top__'}
106
+ section={section}
107
+ collapsed={collapsed}
108
+ isActive={isActive}
109
+ expandedIds={expandedIds}
110
+ onToggle={toggleExpand}
111
+ onNavigate={onNavigate}
112
+ LinkComponent={LinkComponent}
113
+ />
114
+ ))}
115
+ </nav>
116
+
117
+ {/* Bottom items (Settings, Help, etc.) */}
118
+ {bottomItems.length > 0 && (
119
+ <div style={{
120
+ borderTop: '1px solid var(--mn-border-dark, rgba(255,255,255,0.07))',
121
+ padding: '8px 0',
122
+ flexShrink: 0,
123
+ }}>
124
+ {bottomItems.map(item => (
125
+ <NavItem
126
+ key={item.id}
127
+ item={item}
128
+ collapsed={collapsed}
129
+ active={isActive(item.path)}
130
+ onNavigate={onNavigate}
131
+ LinkComponent={LinkComponent}
132
+ />
133
+ ))}
134
+ </div>
135
+ )}
136
+ </aside>
137
+ );
138
+ }
139
+
140
+ // ─── Section ──────────────────────────────────────────────────────────────────
141
+
142
+ function SidebarSection({ section, collapsed, isActive, expandedIds, onToggle, onNavigate, LinkComponent }) {
143
+ return (
144
+ <div style={{ marginBottom: 4 }}>
145
+ {/* Section label */}
146
+ {section.label && !collapsed && (
147
+ <p style={{
148
+ padding: '8px 16px 4px',
149
+ fontSize: 10,
150
+ fontWeight: 600,
151
+ letterSpacing: '0.08em',
152
+ textTransform: 'uppercase',
153
+ color: 'var(--mn-text-muted-dark, #64748B)',
154
+ margin: 0,
155
+ whiteSpace: 'nowrap',
156
+ overflow: 'hidden',
157
+ }}>
158
+ {section.label}
159
+ </p>
160
+ )}
161
+ {section.label && collapsed && <div style={{ height: 8 }} />}
162
+
163
+ {section.items.map(item => (
164
+ <NavItem
165
+ key={item.id}
166
+ item={item}
167
+ collapsed={collapsed}
168
+ active={isActive(item.path)}
169
+ expanded={expandedIds.has(item.id)}
170
+ onToggle={() => item.children?.length && onToggle(item.id)}
171
+ onNavigate={onNavigate}
172
+ LinkComponent={LinkComponent}
173
+ isActive={isActive}
174
+ />
175
+ ))}
176
+ </div>
177
+ );
178
+ }
179
+
180
+ // ─── NavItem ──────────────────────────────────────────────────────────────────
181
+
182
+ function NavItem({ item, collapsed, active, expanded, onToggle, onNavigate, LinkComponent, isActive }) {
183
+ const hasChildren = item.children?.length > 0;
184
+ const isDisabled = Boolean(item.disabled);
185
+
186
+ const itemStyle = {
187
+ display: 'flex',
188
+ alignItems: 'center',
189
+ justifyContent: collapsed ? 'center' : 'flex-start',
190
+ gap: collapsed ? 0 : 10,
191
+ width: '100%',
192
+ padding: collapsed ? '0' : '0 12px',
193
+ margin: '1px 6px',
194
+ width: 'calc(100% - 12px)',
195
+ height: 36,
196
+ borderRadius: 'var(--mn-radius-lg, 0.75rem)',
197
+ fontSize: 'var(--mn-font-size-sm, 0.875rem)',
198
+ fontWeight: 500,
199
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
200
+ opacity: isDisabled ? 0.4 : 1,
201
+ textDecoration: 'none',
202
+ border: 'none',
203
+ outline: 'none',
204
+ transition: 'background-color 150ms, color 150ms',
205
+ whiteSpace: 'nowrap',
206
+ overflow: 'hidden',
207
+ fontFamily: 'var(--mn-font-family, inherit)',
208
+ ...(active
209
+ ? {
210
+ backgroundColor: 'rgba(255,255,255,0.08)',
211
+ color: 'var(--mn-text-primary-dark, #F0F4FF)',
212
+ }
213
+ : {
214
+ backgroundColor: 'transparent',
215
+ color: 'var(--mn-text-secondary-dark, #94A3B8)',
216
+ }),
217
+ };
218
+
219
+ const content = (
220
+ <>
221
+ {/* Icon */}
222
+ <IconWrapper icon={item.icon} active={active} />
223
+
224
+ {/* Label (hidden when collapsed) */}
225
+ {!collapsed && (
226
+ <>
227
+ <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
228
+ {item.label}
229
+ </span>
230
+
231
+ {item.badge && (
232
+ <span style={{
233
+ fontSize: 10, fontWeight: 600,
234
+ padding: '1px 6px',
235
+ borderRadius: 'var(--mn-radius-full, 9999px)',
236
+ backgroundColor: 'rgba(99,102,241,0.2)',
237
+ color: 'var(--mn-color-accent, #8B5CF6)',
238
+ flexShrink: 0,
239
+ }}>
240
+ {item.badge}
241
+ </span>
242
+ )}
243
+
244
+ {hasChildren && (
245
+ <span style={{
246
+ fontSize: 10,
247
+ color: 'var(--mn-text-muted-dark, #64748B)',
248
+ flexShrink: 0,
249
+ transform: expanded ? 'rotate(180deg)' : 'none',
250
+ transition: 'transform 200ms',
251
+ }}>▾</span>
252
+ )}
253
+ </>
254
+ )}
255
+ </>
256
+ );
257
+
258
+ const handleClick = (e) => {
259
+ if (isDisabled) { e.preventDefault(); return; }
260
+ if (hasChildren) { e.preventDefault(); onToggle?.(); return; }
261
+ if (item.external) return; // let native link handle it
262
+ if (onNavigate) { e.preventDefault(); onNavigate(item.path); }
263
+ };
264
+
265
+ const linkProps = {
266
+ href: item.path,
267
+ onClick: handleClick,
268
+ style: itemStyle,
269
+ ...(item.external ? { target: '_blank', rel: 'noopener noreferrer' } : {}),
270
+ title: collapsed ? item.label : undefined,
271
+ };
272
+
273
+ return (
274
+ <>
275
+ {hasChildren
276
+ ? <button style={{ ...itemStyle, background: active ? 'rgba(255,255,255,0.08)' : 'transparent', width: 'calc(100% - 12px)', margin: '1px 6px' }} onClick={handleClick}>{content}</button>
277
+ : <LinkComponent {...linkProps}>{content}</LinkComponent>
278
+ }
279
+
280
+ {/* Children */}
281
+ {hasChildren && expanded && !collapsed && (
282
+ <div style={{ paddingLeft: 16, marginBottom: 4 }}>
283
+ {item.children.map(child => (
284
+ <NavItem
285
+ key={child.id ?? child.path}
286
+ item={{ id: child.id ?? child.path, ...child }}
287
+ collapsed={false}
288
+ active={isActive ? isActive(child.path) : false}
289
+ onNavigate={onNavigate}
290
+ LinkComponent={LinkComponent}
291
+ />
292
+ ))}
293
+ </div>
294
+ )}
295
+ </>
296
+ );
297
+ }
298
+
299
+ // ─── Icon Wrapper ─────────────────────────────────────────────────────────────
300
+
301
+ function IconWrapper({ icon, active }) {
302
+ const style = {
303
+ width: 16,
304
+ height: 16,
305
+ flexShrink: 0,
306
+ color: active ? 'var(--mn-color-primary, #3B82F6)' : 'inherit',
307
+ };
308
+
309
+ if (!icon) return <span style={{ width: 16 }} />;
310
+ if (typeof icon === 'string') return <span style={{ fontSize: 14, width: 16, textAlign: 'center' }}>{icon}</span>;
311
+ if (typeof icon === 'function') return icon({ style });
312
+
313
+ // React element
314
+ return <span style={style}>{icon}</span>;
315
+ }
316
+
317
+ // ─── Collapse Button ──────────────────────────────────────────────────────────
318
+
319
+ function CollapseButton({ collapsed, onCollapse }) {
320
+ return (
321
+ <button
322
+ onClick={onCollapse}
323
+ aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
324
+ style={{
325
+ width: 28,
326
+ height: 28,
327
+ borderRadius: 'var(--mn-radius-md, 0.5rem)',
328
+ background: 'rgba(255,255,255,0.04)',
329
+ border: '1px solid var(--mn-border-dark, rgba(255,255,255,0.07))',
330
+ cursor: 'pointer',
331
+ display: 'flex',
332
+ alignItems: 'center',
333
+ justifyContent: 'center',
334
+ color: 'var(--mn-text-muted-dark, #64748B)',
335
+ fontSize: 12,
336
+ flexShrink: 0,
337
+ transition: 'background-color 150ms',
338
+ }}
339
+ >
340
+ {collapsed ? '›' : '‹'}
341
+ </button>
342
+ );
343
+ }
344
+
345
+ function DefaultLogo() {
346
+ return (
347
+ <div style={{
348
+ width: 24, height: 24,
349
+ borderRadius: 6,
350
+ background: 'var(--mn-color-primary, #3B82F6)',
351
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
352
+ fontSize: 11, fontWeight: 800, color: '#fff',
353
+ }}>
354
+ M
355
+ </div>
356
+ );
357
+ }