@papernote/ui 1.11.6 → 1.13.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/dist/styles.css CHANGED
@@ -2005,6 +2005,10 @@ input:checked + .slider:before{
2005
2005
  width: 50%;
2006
2006
  }
2007
2007
 
2008
+ .w-1\/3{
2009
+ width: 33.333333%;
2010
+ }
2011
+
2008
2012
  .w-1\/4{
2009
2013
  width: 25%;
2010
2014
  }
@@ -2077,6 +2081,10 @@ input:checked + .slider:before{
2077
2081
  width: 1rem;
2078
2082
  }
2079
2083
 
2084
+ .w-4\/5{
2085
+ width: 80%;
2086
+ }
2087
+
2080
2088
  .w-4\/6{
2081
2089
  width: 66.666667%;
2082
2090
  }
@@ -2262,6 +2270,10 @@ input:checked + .slider:before{
2262
2270
  max-width: 200px;
2263
2271
  }
2264
2272
 
2273
+ .max-w-\[75\%\]{
2274
+ max-width: 75%;
2275
+ }
2276
+
2265
2277
  .max-w-\[calc\(100\%-0\.25rem\)\]{
2266
2278
  max-width: calc(100% - 0.25rem);
2267
2279
  }
@@ -3176,6 +3188,14 @@ input:checked + .slider:before{
3176
3188
  border-top-right-radius: 0.5rem;
3177
3189
  }
3178
3190
 
3191
+ .rounded-bl-sm{
3192
+ border-bottom-left-radius: 0.125rem;
3193
+ }
3194
+
3195
+ .rounded-br-sm{
3196
+ border-bottom-right-radius: 0.125rem;
3197
+ }
3198
+
3179
3199
  .border{
3180
3200
  border-width: 1px;
3181
3201
  }
@@ -4406,6 +4426,10 @@ input:checked + .slider:before{
4406
4426
  padding-bottom: 0px;
4407
4427
  }
4408
4428
 
4429
+ .pb-1{
4430
+ padding-bottom: 0.25rem;
4431
+ }
4432
+
4409
4433
  .pb-12{
4410
4434
  padding-bottom: 3rem;
4411
4435
  }
@@ -4715,6 +4739,10 @@ input:checked + .slider:before{
4715
4739
  letter-spacing: 0.05em;
4716
4740
  }
4717
4741
 
4742
+ .tracking-widest{
4743
+ letter-spacing: 0.1em;
4744
+ }
4745
+
4718
4746
  .text-accent-200{
4719
4747
  --tw-text-opacity: 1;
4720
4748
  color: rgb(232 231 224 / var(--tw-text-opacity, 1));
@@ -4750,6 +4778,11 @@ input:checked + .slider:before{
4750
4778
  color: rgb(42 41 34 / var(--tw-text-opacity, 1));
4751
4779
  }
4752
4780
 
4781
+ .text-amber-600{
4782
+ --tw-text-opacity: 1;
4783
+ color: rgb(217 119 6 / var(--tw-text-opacity, 1));
4784
+ }
4785
+
4753
4786
  .text-blue-500{
4754
4787
  --tw-text-opacity: 1;
4755
4788
  color: rgb(59 130 246 / var(--tw-text-opacity, 1));
@@ -4889,6 +4922,11 @@ input:checked + .slider:before{
4889
4922
  color: rgb(231 229 228 / var(--tw-text-opacity, 1));
4890
4923
  }
4891
4924
 
4925
+ .text-primary-200{
4926
+ --tw-text-opacity: 1;
4927
+ color: rgb(226 232 240 / var(--tw-text-opacity, 1));
4928
+ }
4929
+
4892
4930
  .text-primary-500{
4893
4931
  --tw-text-opacity: 1;
4894
4932
  color: rgb(100 116 139 / var(--tw-text-opacity, 1));
@@ -4914,6 +4952,11 @@ input:checked + .slider:before{
4914
4952
  color: rgb(15 23 42 / var(--tw-text-opacity, 1));
4915
4953
  }
4916
4954
 
4955
+ .text-purple-600{
4956
+ --tw-text-opacity: 1;
4957
+ color: rgb(147 51 234 / var(--tw-text-opacity, 1));
4958
+ }
4959
+
4917
4960
  .text-purple-700{
4918
4961
  --tw-text-opacity: 1;
4919
4962
  color: rgb(126 34 206 / var(--tw-text-opacity, 1));
@@ -5701,6 +5744,16 @@ input:checked + .slider:before{
5701
5744
  }
5702
5745
  }
5703
5746
 
5747
+ .placeholder\:text-ink-400::-moz-placeholder{
5748
+ --tw-text-opacity: 1;
5749
+ color: rgb(168 162 158 / var(--tw-text-opacity, 1));
5750
+ }
5751
+
5752
+ .placeholder\:text-ink-400::placeholder{
5753
+ --tw-text-opacity: 1;
5754
+ color: rgb(168 162 158 / var(--tw-text-opacity, 1));
5755
+ }
5756
+
5704
5757
  .first\:rounded-t-lg:first-child{
5705
5758
  border-top-left-radius: 0.5rem;
5706
5759
  border-top-right-radius: 0.5rem;
@@ -6279,6 +6332,11 @@ input:checked + .slider:before{
6279
6332
  border-color: rgb(239 68 68 / var(--tw-border-opacity, 1));
6280
6333
  }
6281
6334
 
6335
+ .focus\:border-primary-300:focus{
6336
+ --tw-border-opacity: 1;
6337
+ border-color: rgb(203 213 225 / var(--tw-border-opacity, 1));
6338
+ }
6339
+
6282
6340
  .focus\:border-primary-500:focus{
6283
6341
  --tw-border-opacity: 1;
6284
6342
  border-color: rgb(100 116 139 / var(--tw-border-opacity, 1));
@@ -6375,6 +6433,11 @@ input:checked + .slider:before{
6375
6433
  --tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity, 1));
6376
6434
  }
6377
6435
 
6436
+ .focus\:ring-primary-300:focus{
6437
+ --tw-ring-opacity: 1;
6438
+ --tw-ring-color: rgb(203 213 225 / var(--tw-ring-opacity, 1));
6439
+ }
6440
+
6378
6441
  .focus\:ring-primary-500:focus{
6379
6442
  --tw-ring-opacity: 1;
6380
6443
  --tw-ring-color: rgb(100 116 139 / var(--tw-ring-opacity, 1));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papernote/ui",
3
- "version": "1.11.6",
3
+ "version": "1.13.0",
4
4
  "type": "module",
5
5
  "description": "A modern React component library with a paper notebook aesthetic - minimal, professional, and expressive",
6
6
  "main": "dist/index.js",
@@ -109,6 +109,7 @@ export function AdminModal({
109
109
  className="flex-1 overflow-y-auto min-h-0 h-0 px-6 py-6 admin-modal-form"
110
110
  onSubmit={handleFormSubmit}
111
111
  id={formId}
112
+ noValidate
112
113
  >
113
114
  {activeTab?.content || children}
114
115
  </form>
@@ -0,0 +1,214 @@
1
+ import React, { forwardRef } from 'react';
2
+ import { Send, Bot, User } from 'lucide-react';
3
+
4
+ /** A single message in the chat thread */
5
+ export interface ChatMessage {
6
+ /** Unique message identifier */
7
+ id: string;
8
+ /** Who sent the message */
9
+ role: 'user' | 'assistant';
10
+ /** Message content */
11
+ content: string;
12
+ /** When the message was sent */
13
+ timestamp: Date;
14
+ }
15
+
16
+ export interface ChatUIProps {
17
+ /** Message thread to display */
18
+ messages: ChatMessage[];
19
+ /** Controlled input value */
20
+ inputValue: string;
21
+ /** Show typing indicator */
22
+ isLoading?: boolean;
23
+ /** Input placeholder text */
24
+ placeholder?: string;
25
+ /** Clickable suggested question chips */
26
+ suggestedQuestions?: string[];
27
+ /** Input change handler */
28
+ onInputChange: (value: string) => void;
29
+ /** Send message handler */
30
+ onSend: () => void;
31
+ /** Suggested question click handler */
32
+ onSuggestedQuestionClick?: (question: string) => void;
33
+ /** Ref for auto-scrolling to bottom of messages */
34
+ messagesEndRef?: React.RefObject<HTMLDivElement | null>;
35
+ /** Container height (CSS value) */
36
+ height?: string;
37
+ /** Additional CSS classes */
38
+ className?: string;
39
+ }
40
+
41
+ function formatRelativeTime(date: Date): string {
42
+ const now = new Date();
43
+ const diffMs = now.getTime() - date.getTime();
44
+ const diffMins = Math.floor(diffMs / 60000);
45
+ const diffHours = Math.floor(diffMs / 3600000);
46
+ const diffDays = Math.floor(diffMs / 86400000);
47
+
48
+ if (diffMins < 1) return 'Just now';
49
+ if (diffMins < 60) return `${diffMins}m ago`;
50
+ if (diffHours < 24) return `${diffHours}h ago`;
51
+ if (diffDays < 7) return `${diffDays}d ago`;
52
+
53
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
54
+ }
55
+
56
+ /**
57
+ * ChatUI - Conversational chat interface component.
58
+ *
59
+ * Renders a scrollable message thread with user/assistant bubbles,
60
+ * a typing indicator, an input bar with send button (Enter to send,
61
+ * Shift+Enter for newline), and optional suggested question chips.
62
+ *
63
+ * @example
64
+ * ```tsx
65
+ * <ChatUI
66
+ * messages={messages}
67
+ * inputValue={input}
68
+ * isLoading={loading}
69
+ * onInputChange={setInput}
70
+ * onSend={handleSend}
71
+ * suggestedQuestions={['What are my top deals?']}
72
+ * onSuggestedQuestionClick={handleQuestion}
73
+ * />
74
+ * ```
75
+ */
76
+ const ChatUI = forwardRef<HTMLDivElement, ChatUIProps>(({
77
+ messages,
78
+ inputValue,
79
+ isLoading = false,
80
+ placeholder = 'Ask a question...',
81
+ suggestedQuestions = [],
82
+ onInputChange,
83
+ onSend,
84
+ onSuggestedQuestionClick,
85
+ messagesEndRef,
86
+ height = '600px',
87
+ className = '',
88
+ }, ref) => {
89
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
90
+ if (e.key === 'Enter' && !e.shiftKey) {
91
+ e.preventDefault();
92
+ if (inputValue.trim()) {
93
+ onSend();
94
+ }
95
+ }
96
+ };
97
+
98
+ return (
99
+ <div
100
+ ref={ref}
101
+ className={`flex flex-col bg-white rounded-lg border border-paper-200 overflow-hidden ${className}`}
102
+ style={{ height }}
103
+ >
104
+ {/* Messages area */}
105
+ <div className="flex-1 overflow-y-auto p-4 space-y-4">
106
+ {messages.length === 0 && !isLoading && (
107
+ <div className="flex flex-col items-center justify-center h-full text-center">
108
+ <Bot className="w-10 h-10 text-ink-300 mb-3" />
109
+ <p className="text-ink-500 text-sm">No messages yet. Start a conversation!</p>
110
+ </div>
111
+ )}
112
+
113
+ {messages.map((message) => (
114
+ <div
115
+ key={message.id}
116
+ className={`flex gap-3 ${message.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}
117
+ >
118
+ {/* Avatar */}
119
+ <div
120
+ className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
121
+ message.role === 'user'
122
+ ? 'bg-primary-100 text-primary-600'
123
+ : 'bg-paper-200 text-ink-500'
124
+ }`}
125
+ >
126
+ {message.role === 'user' ? (
127
+ <User className="w-4 h-4" />
128
+ ) : (
129
+ <Bot className="w-4 h-4" />
130
+ )}
131
+ </div>
132
+
133
+ {/* Bubble */}
134
+ <div
135
+ className={`max-w-[75%] rounded-xl px-4 py-2.5 ${
136
+ message.role === 'user'
137
+ ? 'bg-primary-500 text-white rounded-br-sm'
138
+ : 'bg-paper-100 text-ink-800 border border-paper-200 rounded-bl-sm'
139
+ }`}
140
+ >
141
+ <p className="text-sm whitespace-pre-wrap">{message.content}</p>
142
+ <p
143
+ className={`text-[10px] mt-1 ${
144
+ message.role === 'user' ? 'text-primary-200' : 'text-ink-400'
145
+ }`}
146
+ >
147
+ {formatRelativeTime(message.timestamp)}
148
+ </p>
149
+ </div>
150
+ </div>
151
+ ))}
152
+
153
+ {/* Typing indicator */}
154
+ {isLoading && (
155
+ <div className="flex gap-3">
156
+ <div className="flex-shrink-0 w-8 h-8 rounded-full bg-paper-200 text-ink-500 flex items-center justify-center">
157
+ <Bot className="w-4 h-4" />
158
+ </div>
159
+ <div className="bg-paper-100 border border-paper-200 rounded-xl rounded-bl-sm px-4 py-3">
160
+ <div className="flex gap-1.5">
161
+ <span className="w-2 h-2 bg-ink-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
162
+ <span className="w-2 h-2 bg-ink-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
163
+ <span className="w-2 h-2 bg-ink-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
164
+ </div>
165
+ </div>
166
+ </div>
167
+ )}
168
+
169
+ {messagesEndRef && <div ref={messagesEndRef} />}
170
+ </div>
171
+
172
+ {/* Suggested questions */}
173
+ {suggestedQuestions.length > 0 && messages.length === 0 && (
174
+ <div className="px-4 pb-2 flex flex-wrap gap-2">
175
+ {suggestedQuestions.map((question, index) => (
176
+ <button
177
+ key={index}
178
+ onClick={() => onSuggestedQuestionClick?.(question)}
179
+ className="text-xs px-3 py-1.5 rounded-full border border-primary-200 text-primary-700 bg-primary-50 hover:bg-primary-100 transition-colors"
180
+ >
181
+ {question}
182
+ </button>
183
+ ))}
184
+ </div>
185
+ )}
186
+
187
+ {/* Input area */}
188
+ <div className="border-t border-paper-200 p-3 bg-paper-50">
189
+ <div className="flex items-end gap-2">
190
+ <textarea
191
+ value={inputValue}
192
+ onChange={(e) => onInputChange(e.target.value)}
193
+ onKeyDown={handleKeyDown}
194
+ placeholder={placeholder}
195
+ rows={1}
196
+ className="flex-1 resize-none rounded-lg border border-paper-300 bg-white px-3 py-2 text-sm text-ink-800 placeholder:text-ink-400 focus:outline-none focus:ring-2 focus:ring-primary-300 focus:border-primary-300"
197
+ />
198
+ <button
199
+ onClick={onSend}
200
+ disabled={!inputValue.trim() || isLoading}
201
+ className="flex-shrink-0 p-2 rounded-lg bg-primary-500 text-white hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
202
+ aria-label="Send message"
203
+ >
204
+ <Send className="w-4 h-4" />
205
+ </button>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ );
210
+ });
211
+
212
+ ChatUI.displayName = 'ChatUI';
213
+
214
+ export default ChatUI;
@@ -0,0 +1,244 @@
1
+ import { forwardRef } from 'react';
2
+ import { TrendingUp, AlertTriangle, Target, Lightbulb, RefreshCw, BrainCircuit } from 'lucide-react';
3
+
4
+ /** A structured analytics insight */
5
+ export interface AnalyticsInsight {
6
+ /** Unique insight identifier */
7
+ id: string;
8
+ /** Short insight title */
9
+ title: string;
10
+ /** Descriptive summary */
11
+ summary: string;
12
+ /** Insight category */
13
+ type: 'trend' | 'anomaly' | 'forecast' | 'recommendation';
14
+ /** Confidence score (0–1) */
15
+ confidence: number;
16
+ /** Optional payload */
17
+ data?: any;
18
+ /** When the insight was generated */
19
+ timestamp: Date;
20
+ }
21
+
22
+ export interface InsightsPanelUIProps {
23
+ /** Array of insights to display */
24
+ insights: AnalyticsInsight[];
25
+ /** Show loading skeleton */
26
+ isLoading?: boolean;
27
+ /** Active filter value */
28
+ filter?: string;
29
+ /** Maximum number of insights to show */
30
+ maxInsights?: number;
31
+ /** Filter change handler */
32
+ onFilterChange?: (filter: string) => void;
33
+ /** Refresh handler */
34
+ onRefresh?: () => void;
35
+ /** Additional CSS classes */
36
+ className?: string;
37
+ }
38
+
39
+ const FILTER_OPTIONS = [
40
+ { key: 'all', label: 'All' },
41
+ { key: 'trend', label: 'Trends' },
42
+ { key: 'anomaly', label: 'Anomalies' },
43
+ { key: 'forecast', label: 'Forecasts' },
44
+ { key: 'recommendation', label: 'Recommendations' },
45
+ ] as const;
46
+
47
+ function getTypeIcon(type: AnalyticsInsight['type']) {
48
+ switch (type) {
49
+ case 'trend':
50
+ return <TrendingUp className="w-4 h-4 text-green-600" />;
51
+ case 'anomaly':
52
+ return <AlertTriangle className="w-4 h-4 text-red-600" />;
53
+ case 'forecast':
54
+ return <Target className="w-4 h-4 text-purple-600" />;
55
+ case 'recommendation':
56
+ return <Lightbulb className="w-4 h-4 text-amber-600" />;
57
+ }
58
+ }
59
+
60
+ function getTypeBgColor(type: AnalyticsInsight['type']): string {
61
+ switch (type) {
62
+ case 'trend': return 'bg-green-50';
63
+ case 'anomaly': return 'bg-red-50';
64
+ case 'forecast': return 'bg-purple-50';
65
+ case 'recommendation': return 'bg-amber-50';
66
+ }
67
+ }
68
+
69
+ function getConfidenceBadge(confidence: number) {
70
+ const pct = Math.round(confidence * 100);
71
+ let colorClasses: string;
72
+
73
+ if (confidence >= 0.8) {
74
+ colorClasses = 'bg-green-100 text-green-700';
75
+ } else if (confidence >= 0.6) {
76
+ colorClasses = 'bg-yellow-100 text-yellow-700';
77
+ } else {
78
+ colorClasses = 'bg-red-100 text-red-700';
79
+ }
80
+
81
+ return (
82
+ <span className={`inline-flex items-center text-[10px] font-medium px-1.5 py-0.5 rounded-full ${colorClasses}`}>
83
+ {pct}%
84
+ </span>
85
+ );
86
+ }
87
+
88
+ function formatRelativeTime(date: Date): string {
89
+ const now = new Date();
90
+ const diffMs = now.getTime() - date.getTime();
91
+ const diffMins = Math.floor(diffMs / 60000);
92
+ const diffHours = Math.floor(diffMs / 3600000);
93
+ const diffDays = Math.floor(diffMs / 86400000);
94
+
95
+ if (diffMins < 1) return 'Just now';
96
+ if (diffMins < 60) return `${diffMins}m ago`;
97
+ if (diffHours < 24) return `${diffHours}h ago`;
98
+ if (diffDays < 7) return `${diffDays}d ago`;
99
+
100
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
101
+ }
102
+
103
+ /**
104
+ * InsightsPanelUI - Structured display of analytics insights.
105
+ *
106
+ * Renders a header with title and refresh button, filter chips
107
+ * (All / Trends / Anomalies / Forecasts / Recommendations), and
108
+ * insight cards with type icon, title, summary, confidence badge,
109
+ * and relative timestamp. Includes loading skeleton and empty state.
110
+ *
111
+ * @example
112
+ * ```tsx
113
+ * <InsightsPanelUI
114
+ * insights={insights}
115
+ * isLoading={loading}
116
+ * filter={activeFilter}
117
+ * maxInsights={8}
118
+ * onFilterChange={setFilter}
119
+ * onRefresh={refreshInsights}
120
+ * />
121
+ * ```
122
+ */
123
+ const InsightsPanelUI = forwardRef<HTMLDivElement, InsightsPanelUIProps>(({
124
+ insights,
125
+ isLoading = false,
126
+ filter = 'all',
127
+ maxInsights,
128
+ onFilterChange,
129
+ onRefresh,
130
+ className = '',
131
+ }, ref) => {
132
+ const filtered = filter === 'all'
133
+ ? insights
134
+ : insights.filter((i) => i.type === filter);
135
+
136
+ const displayed = maxInsights ? filtered.slice(0, maxInsights) : filtered;
137
+
138
+ return (
139
+ <div ref={ref} className={`bg-white rounded-lg border border-paper-200 ${className}`}>
140
+ {/* Header */}
141
+ <div className="flex items-center justify-between px-5 py-4 border-b border-paper-200">
142
+ <h3 className="text-lg font-semibold text-ink-900">AI Insights</h3>
143
+ {onRefresh && (
144
+ <button
145
+ onClick={onRefresh}
146
+ disabled={isLoading}
147
+ className="p-1.5 rounded-md text-ink-500 hover:text-ink-700 hover:bg-paper-100 disabled:opacity-40 transition-colors"
148
+ aria-label="Refresh insights"
149
+ >
150
+ <RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
151
+ </button>
152
+ )}
153
+ </div>
154
+
155
+ {/* Filter chips */}
156
+ {onFilterChange && (
157
+ <div className="px-5 pt-3 pb-1 flex flex-wrap gap-2">
158
+ {FILTER_OPTIONS.map((opt) => (
159
+ <button
160
+ key={opt.key}
161
+ onClick={() => onFilterChange(opt.key)}
162
+ className={`text-xs font-medium px-3 py-1.5 rounded-full transition-colors ${
163
+ filter === opt.key
164
+ ? 'bg-primary-500 text-white'
165
+ : 'bg-paper-100 text-ink-600 hover:bg-paper-200'
166
+ }`}
167
+ >
168
+ {opt.label}
169
+ </button>
170
+ ))}
171
+ </div>
172
+ )}
173
+
174
+ {/* Content */}
175
+ <div className="p-5 space-y-3">
176
+ {/* Loading skeleton */}
177
+ {isLoading && displayed.length === 0 && (
178
+ <div className="space-y-3">
179
+ {[1, 2, 3].map((i) => (
180
+ <div key={i} className="animate-pulse rounded-lg border border-paper-200 p-4">
181
+ <div className="flex items-center gap-3 mb-2">
182
+ <div className="w-8 h-8 bg-paper-200 rounded-lg" />
183
+ <div className="flex-1">
184
+ <div className="h-4 bg-paper-200 rounded w-2/3 mb-1" />
185
+ <div className="h-3 bg-paper-100 rounded w-1/3" />
186
+ </div>
187
+ </div>
188
+ <div className="h-3 bg-paper-100 rounded w-full mb-1" />
189
+ <div className="h-3 bg-paper-100 rounded w-4/5" />
190
+ </div>
191
+ ))}
192
+ </div>
193
+ )}
194
+
195
+ {/* Empty state */}
196
+ {!isLoading && displayed.length === 0 && (
197
+ <div className="text-center py-8">
198
+ <BrainCircuit className="w-10 h-10 text-ink-300 mx-auto mb-3" />
199
+ <p className="text-ink-500 text-sm">
200
+ {filter === 'all'
201
+ ? 'No insights available yet.'
202
+ : `No ${filter} insights found.`}
203
+ </p>
204
+ </div>
205
+ )}
206
+
207
+ {/* Insight cards */}
208
+ {displayed.map((insight) => (
209
+ <div
210
+ key={insight.id}
211
+ className="rounded-lg border border-paper-200 p-4 hover:shadow-sm transition-shadow"
212
+ >
213
+ <div className="flex items-start gap-3">
214
+ {/* Type icon */}
215
+ <div className={`flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${getTypeBgColor(insight.type)}`}>
216
+ {getTypeIcon(insight.type)}
217
+ </div>
218
+
219
+ <div className="flex-1 min-w-0">
220
+ {/* Title row */}
221
+ <div className="flex items-center justify-between gap-2 mb-1">
222
+ <h4 className="text-sm font-medium text-ink-900 truncate">{insight.title}</h4>
223
+ {getConfidenceBadge(insight.confidence)}
224
+ </div>
225
+
226
+ {/* Summary */}
227
+ <p className="text-xs text-ink-600 leading-relaxed">{insight.summary}</p>
228
+
229
+ {/* Timestamp */}
230
+ <p className="text-[10px] text-ink-400 mt-1.5">
231
+ {formatRelativeTime(insight.timestamp)}
232
+ </p>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ ))}
237
+ </div>
238
+ </div>
239
+ );
240
+ });
241
+
242
+ InsightsPanelUI.displayName = 'InsightsPanelUI';
243
+
244
+ export default InsightsPanelUI;
@@ -331,8 +331,26 @@ export default function Sidebar({
331
331
  {/* Navigation */}
332
332
  <nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto">
333
333
  {items.map((item) => {
334
- // Render separator
334
+ // Render separator or section header
335
335
  if (item.separator) {
336
+ // Section header: separator with a label
337
+ if (item.label) {
338
+ return (
339
+ <div
340
+ key={item.id}
341
+ className="mt-6 mb-2 px-3"
342
+ data-testid={item.dataAttributes?.['data-testid'] || `sidebar-section-${item.id}`}
343
+ {...item.dataAttributes}
344
+ >
345
+ <div className="border-t border-paper-300 pt-3">
346
+ <span className="text-[10px] font-semibold uppercase tracking-widest text-ink-400">
347
+ {item.label}
348
+ </span>
349
+ </div>
350
+ </div>
351
+ );
352
+ }
353
+ // Plain separator: just a line
336
354
  return (
337
355
  <div
338
356
  key={item.id}
@@ -640,3 +640,9 @@ export {
640
640
  Responsive,
641
641
  } from '../context/MobileContext';
642
642
  export type { MobileContextValue, MobileProviderProps } from '../context/MobileContext';
643
+
644
+ // Chat & AI Components
645
+ export { default as ChatUI } from './ChatUI';
646
+ export type { ChatUIProps, ChatMessage } from './ChatUI';
647
+ export { default as InsightsPanelUI } from './InsightsPanelUI';
648
+ export type { InsightsPanelUIProps, AnalyticsInsight } from './InsightsPanelUI';