@1889ca/ui 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/components/ContextMenu.css +5 -3
  3. package/src/components/ContextMenu.jsx +2 -14
  4. package/src/components/DraggablePanel.css +2 -1
  5. package/src/components/DraggablePanel.jsx +4 -17
  6. package/src/components/EmptyState.css +36 -0
  7. package/src/components/EmptyState.jsx +27 -0
  8. package/src/components/ItemCard.css +12 -1
  9. package/src/components/ItemCard.jsx +8 -3
  10. package/src/components/ItemGrid.css +22 -0
  11. package/src/components/Lightbox.css +12 -2
  12. package/src/components/Lightbox.jsx +2 -2
  13. package/src/components/LoginCard.css +1 -1
  14. package/src/components/Modal.css +5 -4
  15. package/src/components/PageHeader.css +15 -1
  16. package/src/components/Panel.css +1 -2
  17. package/src/components/Panel.jsx +2 -2
  18. package/src/components/Skeleton.css +79 -0
  19. package/src/components/Skeleton.jsx +47 -0
  20. package/src/components/SlidePanel.css +7 -1
  21. package/src/components/StatusBar.css +38 -1
  22. package/src/components/TabBar.css +53 -0
  23. package/src/components/TabBar.jsx +28 -0
  24. package/src/components/ThemeToggle.css +34 -0
  25. package/src/components/ThemeToggle.jsx +44 -0
  26. package/src/components/Toast.css +102 -0
  27. package/src/components/Toast.jsx +67 -0
  28. package/src/components/Toggle.css +53 -0
  29. package/src/components/Toggle.jsx +28 -0
  30. package/src/components/Tooltip.css +36 -0
  31. package/src/components/Tooltip.jsx +55 -0
  32. package/src/components/UserMenu.css +18 -1
  33. package/src/components/UserMenu.jsx +28 -18
  34. package/src/effects/Particles.jsx +117 -0
  35. package/src/effects/ambient-glow.css +67 -0
  36. package/src/effects/edge-light.css +69 -0
  37. package/src/effects/glass-tint.css +47 -0
  38. package/src/effects/glow-focus.css +64 -0
  39. package/src/effects/haptic.css +52 -0
  40. package/src/effects/particles.css +10 -0
  41. package/src/effects/scroll-parallax.css +46 -0
  42. package/src/hooks/useClickOutside.js +26 -0
  43. package/src/hooks/useLocalStorage.js +25 -0
  44. package/src/hooks/useMagnet.js +60 -0
  45. package/src/hooks/useMediaQuery.js +24 -0
  46. package/src/hooks/useScrollParallax.js +39 -0
  47. package/src/hooks/useSound.js +106 -0
  48. package/src/hooks/useTheme.js +90 -0
  49. package/src/index.js +36 -0
  50. package/src/styles/primitives.css +48 -1
  51. package/src/styles/tokens.css +89 -0
  52. package/src/utils/accentColors.js +85 -0
  53. package/src/utils/viewTransition.js +16 -0
@@ -0,0 +1,53 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+
3
+ .ui-tabbar {
4
+ display: flex;
5
+ gap: 2px;
6
+ padding: 0 0.35rem;
7
+ border-bottom: 1px solid var(--ui-border);
8
+ flex-shrink: 0;
9
+ }
10
+
11
+ .ui-tabbar--full .ui-tabbar-tab {
12
+ flex: 1;
13
+ text-align: center;
14
+ }
15
+
16
+ .ui-tabbar-tab {
17
+ position: relative;
18
+ padding: 0.5rem 0.75rem;
19
+ background: none;
20
+ border: none;
21
+ color: var(--ui-text-subtle);
22
+ font-size: 0.72rem;
23
+ font-weight: 500;
24
+ text-transform: uppercase;
25
+ letter-spacing: 0.05em;
26
+ cursor: pointer;
27
+ transition: color 0.15s var(--ui-ease);
28
+ }
29
+
30
+ .ui-tabbar-tab::after {
31
+ content: '';
32
+ position: absolute;
33
+ left: 0.35rem;
34
+ right: 0.35rem;
35
+ bottom: -1px;
36
+ height: 2px;
37
+ border-radius: 1px;
38
+ background: transparent;
39
+ transition: background 0.2s var(--ui-ease), box-shadow 0.2s var(--ui-ease);
40
+ }
41
+
42
+ .ui-tabbar-tab:hover {
43
+ color: var(--ui-text-muted);
44
+ }
45
+
46
+ .ui-tabbar-tab--active {
47
+ color: var(--ui-accent);
48
+ }
49
+
50
+ .ui-tabbar-tab--active::after {
51
+ background: var(--ui-accent);
52
+ box-shadow: 0 0 8px var(--ui-accent-glow);
53
+ }
@@ -0,0 +1,28 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+ import './TabBar.css';
3
+
4
+ /**
5
+ * Standalone tab bar with animated underline indicator.
6
+ * @param {Array} tabs - [{ id, label }]
7
+ * @param {string} activeTab - Currently active tab id
8
+ * @param {function} onChange - Called with tab id
9
+ * @param {string} variant - 'default' | 'full-width' (default: 'default')
10
+ */
11
+ export default function TabBar({ tabs, activeTab, onChange, variant = 'default' }) {
12
+ return (
13
+ <nav className={`ui-tabbar${variant === 'full-width' ? ' ui-tabbar--full' : ''}`} role="tablist">
14
+ {tabs.map((tab) => (
15
+ <button
16
+ key={tab.id}
17
+ type="button"
18
+ role="tab"
19
+ aria-selected={activeTab === tab.id}
20
+ className={`ui-tabbar-tab${activeTab === tab.id ? ' ui-tabbar-tab--active' : ''}`}
21
+ onClick={() => onChange?.(tab.id)}
22
+ >
23
+ {tab.label}
24
+ </button>
25
+ ))}
26
+ </nav>
27
+ );
28
+ }
@@ -0,0 +1,34 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+
3
+ .ui-theme-toggle {
4
+ display: inline-flex;
5
+ align-items: center;
6
+ justify-content: center;
7
+ width: 32px;
8
+ height: 32px;
9
+ border-radius: var(--ui-radius-sm);
10
+ border: 1px solid var(--ui-border);
11
+ background: var(--ui-surface);
12
+ color: var(--ui-text-muted);
13
+ cursor: pointer;
14
+ padding: 0;
15
+ transition: color 0.2s var(--ui-ease), border-color 0.2s var(--ui-ease), background 0.2s var(--ui-ease), transform 0.2s var(--ui-ease-spring);
16
+ }
17
+
18
+ .ui-theme-toggle:hover {
19
+ color: var(--ui-text);
20
+ border-color: var(--ui-border-strong);
21
+ background: var(--ui-surface-raised);
22
+ }
23
+
24
+ .ui-theme-toggle:active {
25
+ transform: scale(0.92);
26
+ }
27
+
28
+ .ui-theme-toggle svg {
29
+ transition: transform 0.3s var(--ui-ease-spring);
30
+ }
31
+
32
+ .ui-theme-toggle:hover svg {
33
+ transform: rotate(15deg);
34
+ }
@@ -0,0 +1,44 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+ import useTheme from '../hooks/useTheme.js';
3
+
4
+ /**
5
+ * Theme toggle button — cycles between dark/light.
6
+ * Uses useTheme internally; can also accept external theme state.
7
+ *
8
+ * @param {string} [size='20'] - Icon size
9
+ * @param {string} [className] - Additional class names
10
+ * @param {object} [themeState] - External { resolvedTheme, toggle } from useTheme (optional)
11
+ */
12
+ export default function ThemeToggle({ size = 20, className, themeState }) {
13
+ const internal = useTheme();
14
+ const { resolvedTheme, toggle } = themeState || internal;
15
+ const isDark = resolvedTheme === 'dark';
16
+
17
+ return (
18
+ <button
19
+ type="button"
20
+ className={`ui-theme-toggle${className ? ` ${className}` : ''}`}
21
+ onClick={toggle}
22
+ aria-label={`Switch to ${isDark ? 'light' : 'dark'} mode`}
23
+ title={`Switch to ${isDark ? 'light' : 'dark'} mode`}
24
+ >
25
+ {isDark ? (
26
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
27
+ <circle cx="12" cy="12" r="5" />
28
+ <line x1="12" y1="1" x2="12" y2="3" />
29
+ <line x1="12" y1="21" x2="12" y2="23" />
30
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
31
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
32
+ <line x1="1" y1="12" x2="3" y2="12" />
33
+ <line x1="21" y1="12" x2="23" y2="12" />
34
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
35
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
36
+ </svg>
37
+ ) : (
38
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
39
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
40
+ </svg>
41
+ )}
42
+ </button>
43
+ );
44
+ }
@@ -0,0 +1,102 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+
3
+ .ui-toast-container {
4
+ position: fixed;
5
+ bottom: 40px;
6
+ right: 1rem;
7
+ z-index: 1100;
8
+ display: flex;
9
+ flex-direction: column;
10
+ gap: 0.5rem;
11
+ pointer-events: none;
12
+ max-width: calc(100vw - 2rem);
13
+ }
14
+
15
+ .ui-toast {
16
+ display: flex;
17
+ align-items: center;
18
+ gap: 0.6rem;
19
+ padding: 0.6rem 1rem;
20
+ min-width: 220px;
21
+ max-width: 400px;
22
+ border-radius: var(--ui-radius);
23
+ border: 1px solid var(--ui-border);
24
+ background: var(--ui-glass-heavy);
25
+ backdrop-filter: blur(var(--ui-glass-blur-heavy));
26
+ box-shadow: var(--ui-shadow-float);
27
+ font-size: 0.82rem;
28
+ color: var(--ui-text);
29
+ cursor: pointer;
30
+ pointer-events: auto;
31
+ animation: ui-toast-in 0.35s var(--ui-ease-spring);
32
+ }
33
+
34
+ .ui-toast--leaving {
35
+ animation: ui-toast-out 0.3s var(--ui-ease) forwards;
36
+ }
37
+
38
+ @keyframes ui-toast-in {
39
+ from {
40
+ opacity: 0;
41
+ transform: translateX(100%) scale(0.95);
42
+ }
43
+ to {
44
+ opacity: 1;
45
+ transform: translateX(0) scale(1);
46
+ }
47
+ }
48
+
49
+ @keyframes ui-toast-out {
50
+ to {
51
+ opacity: 0;
52
+ transform: translateX(30%) scale(0.95);
53
+ max-height: 0;
54
+ padding-top: 0;
55
+ padding-bottom: 0;
56
+ margin-bottom: -0.5rem;
57
+ overflow: hidden;
58
+ }
59
+ }
60
+
61
+ .ui-toast-icon {
62
+ flex-shrink: 0;
63
+ width: 18px;
64
+ height: 18px;
65
+ border-radius: 50%;
66
+ display: flex;
67
+ align-items: center;
68
+ justify-content: center;
69
+ font-size: 0.7rem;
70
+ font-weight: 700;
71
+ }
72
+
73
+ .ui-toast--info {
74
+ border-left: 3px solid var(--ui-accent);
75
+ }
76
+
77
+ .ui-toast--info .ui-toast-icon {
78
+ background: var(--ui-accent-muted);
79
+ color: var(--ui-accent);
80
+ }
81
+
82
+ .ui-toast--success {
83
+ border-left: 3px solid var(--ui-success);
84
+ }
85
+
86
+ .ui-toast--success .ui-toast-icon {
87
+ background: rgba(52, 211, 153, 0.15);
88
+ color: var(--ui-success);
89
+ }
90
+
91
+ .ui-toast--error {
92
+ border-left: 3px solid var(--ui-danger);
93
+ }
94
+
95
+ .ui-toast--error .ui-toast-icon {
96
+ background: var(--ui-danger-glow);
97
+ color: var(--ui-danger);
98
+ }
99
+
100
+ .ui-toast-message {
101
+ line-height: 1.4;
102
+ }
@@ -0,0 +1,67 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+ import { createContext, useContext, useState, useCallback, useRef } from 'react';
3
+ import './Toast.css';
4
+
5
+ const ToastContext = createContext(null);
6
+
7
+ let toastId = 0;
8
+
9
+ /**
10
+ * Wrap your app in ToastProvider to enable toasts.
11
+ * Use the useToast() hook to show toasts imperatively.
12
+ */
13
+ export function ToastProvider({ children }) {
14
+ const [toasts, setToasts] = useState([]);
15
+ const timers = useRef({});
16
+
17
+ const dismiss = useCallback((id) => {
18
+ setToasts(prev => prev.map(t => t.id === id ? { ...t, leaving: true } : t));
19
+ setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 300);
20
+ }, []);
21
+
22
+ const toast = useCallback(({ message, type = 'info', duration = 4000 }) => {
23
+ const id = ++toastId;
24
+ setToasts(prev => [...prev, { id, message, type, leaving: false }]);
25
+ if (duration > 0) {
26
+ timers.current[id] = setTimeout(() => dismiss(id), duration);
27
+ }
28
+ return id;
29
+ }, [dismiss]);
30
+
31
+ const api = useCallback((message, type) => {
32
+ if (typeof message === 'object') return toast(message);
33
+ return toast({ message, type });
34
+ }, [toast]);
35
+
36
+ api.success = (message, duration) => toast({ message, type: 'success', duration });
37
+ api.error = (message, duration) => toast({ message, type: 'error', duration: duration ?? 6000 });
38
+ api.info = (message, duration) => toast({ message, type: 'info', duration });
39
+
40
+ return (
41
+ <ToastContext.Provider value={api}>
42
+ {children}
43
+ <div className="ui-toast-container" aria-live="polite">
44
+ {toasts.map((t) => (
45
+ <div
46
+ key={t.id}
47
+ className={`ui-toast ui-toast--${t.type}${t.leaving ? ' ui-toast--leaving' : ''}`}
48
+ onClick={() => { clearTimeout(timers.current[t.id]); dismiss(t.id); }}
49
+ >
50
+ <span className="ui-toast-icon">{t.type === 'success' ? '✓' : t.type === 'error' ? '✕' : 'ℹ'}</span>
51
+ <span className="ui-toast-message">{t.message}</span>
52
+ </div>
53
+ ))}
54
+ </div>
55
+ </ToastContext.Provider>
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Returns the toast function.
61
+ * toast('message') or toast.success('done') / toast.error('failed') / toast.info('note')
62
+ */
63
+ export function useToast() {
64
+ const ctx = useContext(ToastContext);
65
+ if (!ctx) throw new Error('useToast requires <ToastProvider>');
66
+ return ctx;
67
+ }
@@ -0,0 +1,53 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+
3
+ .ui-toggle {
4
+ display: inline-flex;
5
+ align-items: center;
6
+ gap: 0.5rem;
7
+ cursor: pointer;
8
+ }
9
+
10
+ .ui-toggle--disabled {
11
+ opacity: 0.4;
12
+ cursor: not-allowed;
13
+ }
14
+
15
+ .ui-toggle-track {
16
+ position: relative;
17
+ width: 36px;
18
+ height: 20px;
19
+ border-radius: 10px;
20
+ border: 1px solid var(--ui-border-strong);
21
+ background: var(--ui-surface);
22
+ cursor: inherit;
23
+ padding: 0;
24
+ transition: background 0.2s var(--ui-ease), border-color 0.2s var(--ui-ease), box-shadow 0.2s var(--ui-ease);
25
+ }
26
+
27
+ .ui-toggle-track--on {
28
+ background: var(--ui-accent);
29
+ border-color: var(--ui-accent);
30
+ box-shadow: 0 0 12px var(--ui-accent-glow);
31
+ }
32
+
33
+ .ui-toggle-knob {
34
+ position: absolute;
35
+ top: 2px;
36
+ left: 2px;
37
+ width: 14px;
38
+ height: 14px;
39
+ border-radius: 50%;
40
+ background: var(--ui-text-muted);
41
+ transition: transform 0.2s var(--ui-ease-spring), background 0.2s var(--ui-ease);
42
+ }
43
+
44
+ .ui-toggle-track--on .ui-toggle-knob {
45
+ transform: translateX(16px);
46
+ background: #fff;
47
+ }
48
+
49
+ .ui-toggle-label {
50
+ font-size: 0.82rem;
51
+ color: var(--ui-text-muted);
52
+ user-select: none;
53
+ }
@@ -0,0 +1,28 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+ import './Toggle.css';
3
+
4
+ /**
5
+ * Toggle switch with animated knob.
6
+ * @param {boolean} checked
7
+ * @param {function} onChange - called with new boolean value
8
+ * @param {string} label - Optional accessible label
9
+ * @param {boolean} disabled
10
+ */
11
+ export default function Toggle({ checked, onChange, label, disabled }) {
12
+ return (
13
+ <label className={`ui-toggle${disabled ? ' ui-toggle--disabled' : ''}`}>
14
+ <button
15
+ type="button"
16
+ role="switch"
17
+ aria-checked={checked}
18
+ aria-label={label}
19
+ className={`ui-toggle-track${checked ? ' ui-toggle-track--on' : ''}`}
20
+ onClick={() => !disabled && onChange?.(!checked)}
21
+ disabled={disabled}
22
+ >
23
+ <span className="ui-toggle-knob" />
24
+ </button>
25
+ {label && <span className="ui-toggle-label">{label}</span>}
26
+ </label>
27
+ );
28
+ }
@@ -0,0 +1,36 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+
3
+ .ui-tooltip-trigger {
4
+ display: inline-flex;
5
+ }
6
+
7
+ .ui-tooltip {
8
+ position: fixed;
9
+ z-index: 1200;
10
+ padding: 0.3rem 0.6rem;
11
+ font-size: 0.72rem;
12
+ font-weight: 500;
13
+ color: var(--ui-text);
14
+ background: var(--ui-glass-heavy);
15
+ backdrop-filter: blur(var(--ui-glass-blur));
16
+ border: 1px solid var(--ui-border-strong);
17
+ border-radius: var(--ui-radius-sm);
18
+ box-shadow: var(--ui-shadow-lg);
19
+ white-space: nowrap;
20
+ pointer-events: none;
21
+ animation: ui-tooltip-in 0.15s var(--ui-ease);
22
+ }
23
+
24
+ @keyframes ui-tooltip-in {
25
+ from { opacity: 0; transform: translate(-50%, -100%) scale(0.95); }
26
+ to { opacity: 1; transform: translate(-50%, -100%) scale(1); }
27
+ }
28
+
29
+ .ui-tooltip--bottom {
30
+ animation-name: ui-tooltip-in-bottom;
31
+ }
32
+
33
+ @keyframes ui-tooltip-in-bottom {
34
+ from { opacity: 0; transform: translate(-50%, 0) scale(0.95); }
35
+ to { opacity: 1; transform: translate(-50%, 0) scale(1); }
36
+ }
@@ -0,0 +1,55 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+ import { useState, useRef, useEffect } from 'react';
3
+ import './Tooltip.css';
4
+
5
+ /**
6
+ * Hover tooltip. Wraps children in a span and positions a floating label.
7
+ * @param {string} text - Tooltip content
8
+ * @param {string} side - 'top' | 'bottom' (default: 'top')
9
+ * @param {number} delay - Show delay in ms (default: 400)
10
+ */
11
+ export default function Tooltip({ text, side = 'top', delay = 400, children }) {
12
+ const [visible, setVisible] = useState(false);
13
+ const [pos, setPos] = useState({ x: 0, y: 0 });
14
+ const ref = useRef(null);
15
+ const tipRef = useRef(null);
16
+ const timer = useRef(null);
17
+
18
+ function show() {
19
+ timer.current = setTimeout(() => {
20
+ if (!ref.current) return;
21
+ const rect = ref.current.getBoundingClientRect();
22
+ const x = rect.left + rect.width / 2;
23
+ const y = side === 'bottom' ? rect.bottom + 6 : rect.top - 6;
24
+ setPos({ x, y });
25
+ setVisible(true);
26
+ }, delay);
27
+ }
28
+
29
+ function hide() {
30
+ clearTimeout(timer.current);
31
+ setVisible(false);
32
+ }
33
+
34
+ useEffect(() => () => clearTimeout(timer.current), []);
35
+
36
+ const transform = side === 'bottom'
37
+ ? 'translate(-50%, 0)'
38
+ : 'translate(-50%, -100%)';
39
+
40
+ return (
41
+ <span ref={ref} className="ui-tooltip-trigger" onMouseEnter={show} onMouseLeave={hide} onFocus={show} onBlur={hide}>
42
+ {children}
43
+ {visible && text && (
44
+ <span
45
+ ref={tipRef}
46
+ className={`ui-tooltip ui-tooltip--${side}`}
47
+ style={{ left: pos.x, top: pos.y, transform }}
48
+ role="tooltip"
49
+ >
50
+ {text}
51
+ </span>
52
+ )}
53
+ </span>
54
+ );
55
+ }
@@ -47,7 +47,7 @@
47
47
  top: calc(100% + 6px);
48
48
  right: 0;
49
49
  min-width: 180px;
50
- background: rgba(18, 18, 24, 0.82);
50
+ background: var(--ui-glass);
51
51
  backdrop-filter: blur(var(--ui-glass-blur-heavy));
52
52
  border: 1px solid var(--ui-border);
53
53
  border-top-color: var(--ui-border-strong);
@@ -97,6 +97,23 @@
97
97
  color: var(--ui-danger);
98
98
  }
99
99
 
100
+ /* Theme toggle row */
101
+ .ui-user-menu-theme {
102
+ display: flex;
103
+ align-items: center;
104
+ gap: 0.5rem;
105
+ }
106
+
107
+ .ui-user-menu-theme-icon {
108
+ display: inline-flex;
109
+ color: var(--ui-text-subtle);
110
+ transition: color 0.15s;
111
+ }
112
+
113
+ .ui-user-menu-theme:hover .ui-user-menu-theme-icon {
114
+ color: var(--ui-accent);
115
+ }
116
+
100
117
  /* Separator */
101
118
  .ui-user-menu-sep {
102
119
  height: 1px;
@@ -1,35 +1,26 @@
1
1
  /** Contract: contracts/packages-ui-components/rules.md */
2
- import { useState, useEffect, useRef } from 'react';
2
+ import { useState, useRef, useCallback } from 'react';
3
+ import useClickOutside from '../hooks/useClickOutside.js';
4
+ import useTheme from '../hooks/useTheme.js';
3
5
 
4
6
  /**
5
7
  * User avatar menu with glassmorphic dropdown.
6
8
  * Trigger shows avatar image or first-letter initials.
7
- * Dropdown shows user name, app-specific items, and a pinned logout action.
9
+ * Dropdown shows user name, theme toggle, app-specific items, and a pinned logout action.
8
10
  *
9
11
  * @param {string} name - User display name
10
12
  * @param {string} [avatar] - Optional avatar URL (falls back to initials)
11
13
  * @param {Array} items - [{ label, action }] app-specific menu items
12
14
  * @param {function} onLogout - Always rendered last with separator + danger style
15
+ * @param {boolean} [showThemeToggle=true] - Show dark/light theme toggle in dropdown
13
16
  */
14
- export default function UserMenu({ name, avatar, items = [], onLogout }) {
17
+ export default function UserMenu({ name, avatar, items = [], onLogout, showThemeToggle = true }) {
15
18
  const [open, setOpen] = useState(false);
16
19
  const ref = useRef(null);
17
20
 
18
- useEffect(() => {
19
- if (!open) return;
20
- function onMouseDown(e) {
21
- if (ref.current && !ref.current.contains(e.target)) setOpen(false);
22
- }
23
- function onKey(e) {
24
- if (e.key === 'Escape') setOpen(false);
25
- }
26
- document.addEventListener('mousedown', onMouseDown);
27
- document.addEventListener('keydown', onKey);
28
- return () => {
29
- document.removeEventListener('mousedown', onMouseDown);
30
- document.removeEventListener('keydown', onKey);
31
- };
32
- }, [open]);
21
+ const close = useCallback(() => setOpen(false), []);
22
+ useClickOutside(ref, close, open);
23
+ const { resolvedTheme, toggle: toggleTheme } = useTheme();
33
24
 
34
25
  const initials = name ? name.charAt(0).toUpperCase() : '?';
35
26
 
@@ -49,6 +40,25 @@ export default function UserMenu({ name, avatar, items = [], onLogout }) {
49
40
  {open && (
50
41
  <div className="ui-user-menu-dropdown">
51
42
  <div className="ui-user-menu-name">{name}</div>
43
+ {showThemeToggle && (
44
+ <button
45
+ className="ui-user-menu-item ui-user-menu-theme"
46
+ onClick={toggleTheme}
47
+ >
48
+ <span className="ui-user-menu-theme-icon">
49
+ {resolvedTheme === 'dark' ? (
50
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
51
+ <circle cx="12" cy="12" r="5" /><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" /><line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /><line x1="18.36" y1="18.36" x2="19.78" y2="19.78" /><line x1="1" y1="12" x2="3" y2="12" /><line x1="21" y1="12" x2="23" y2="12" /><line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /><line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
52
+ </svg>
53
+ ) : (
54
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
55
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
56
+ </svg>
57
+ )}
58
+ </span>
59
+ {resolvedTheme === 'dark' ? 'Light mode' : 'Dark mode'}
60
+ </button>
61
+ )}
52
62
  <div className="ui-user-menu-sep" />
53
63
  {items.map((item, i) => (
54
64
  <button