kyd-shared-badge 0.3.10 → 0.3.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kyd-shared-badge",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -29,6 +29,7 @@ type ChatWidgetProps = Partial<{
29
29
  hintText: string;
30
30
  loginPath: string;
31
31
  headerOffset: 'auto' | 'none' | number;
32
+ developerName: string;
32
33
  }>;
33
34
 
34
35
  // const hexToRgba = (hex: string, alpha: number) => {
@@ -258,8 +259,8 @@ const SharedBadgeDisplay = ({ badgeData, chatProps }: { badgeData: PublicBadgeDa
258
259
  />
259
260
  </Reveal>
260
261
 
261
- {/* Right: Contributing Factors */}
262
- <Reveal className="lg:col-span-4 w-full ml-20 flex flex-col items-start justify-start" delayMs={80}>
262
+ {/* Right: Contributing Factors (hidden on small screens) */}
263
+ <Reveal className="lg:col-span-4 w-full ml-0 lg:ml-20 hidden lg:flex flex-col items-start justify-start" delayMs={80}>
263
264
  <div className={'text-sm font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Top Contributing Factors</div>
264
265
  <div className="space-y-4">
265
266
  {(genreMapping?.['Technical'] || []).map((cat: string) => {
@@ -331,8 +332,8 @@ const SharedBadgeDisplay = ({ badgeData, chatProps }: { badgeData: PublicBadgeDa
331
332
  barHeight={16}
332
333
  />
333
334
  </Reveal>
334
- {/* Right: Contributing Factors */}
335
- <Reveal className="lg:col-span-4 w-full ml-20 flex flex-col items-start justify-start" delayMs={80}>
335
+ {/* Right: Contributing Factors (hidden on small screens) */}
336
+ <Reveal className="lg:col-span-4 w-full ml-0 lg:ml-20 hidden lg:flex flex-col items-start justify-start" delayMs={80}>
336
337
  <div className={'text-sm font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Top Contributing Factors</div>
337
338
  <div className="space-y-4">
338
339
  {genreMapping?.['Risk']?.map((cat: string) => {
@@ -544,7 +545,7 @@ const SharedBadgeDisplay = ({ badgeData, chatProps }: { badgeData: PublicBadgeDa
544
545
  </footer>
545
546
  </Reveal>
546
547
  {/* Floating chat widget */}
547
- <ChatWidget api={chatProps?.api || '/api/chat'} badgeId={badgeId} title={chatProps?.title} hintText={chatProps?.hintText} loginPath={chatProps?.loginPath} headerOffset={chatProps?.headerOffset} />
548
+ <ChatWidget api={chatProps?.api || '/api/chat'} badgeId={badgeId} title={chatProps?.title} hintText={chatProps?.hintText} loginPath={chatProps?.loginPath} headerOffset={chatProps?.headerOffset} developerName={developerName} />
548
549
  </div>
549
550
  </BusinessRulesProvider>
550
551
  );
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
4
5
  import { FiMessageSquare, FiX, FiChevronRight } from 'react-icons/fi';
5
6
  import { useChatStreaming } from './useChatStreaming';
6
7
  import '@chatscope/chat-ui-kit-styles/dist/default/styles.min.css';
@@ -23,13 +24,33 @@ type Props = {
23
24
  // Optional: customize login path and header offset behavior for different host apps
24
25
  loginPath?: string; // e.g., '/login' in enterprise; defaults to '/auth/login'
25
26
  headerOffset?: 'auto' | 'none' | number; // default 'auto' (measure <header>), use 'none' when no header
27
+ developerName?: string; // used for starter questions
26
28
  };
27
29
 
28
- export default function ChatWidget({ api = '/api/chat', title = 'KYD Bot', hintText = 'Ask anything about the developer', badgeId, loginPath = '/auth/login', headerOffset = 'auto' }: Props) {
29
- // Sidebar open state (default expanded)
30
- const [open, setOpen] = useState<boolean>(() => {
31
- try { const s = localStorage.getItem('kydChatSidebarOpen'); return s ? s === '1' : true; } catch { return true; }
32
- });
30
+ export default function ChatWidget({ api = '/api/chat', title = 'KYD Bot', hintText = 'Ask anything about the developer', badgeId, loginPath = '/auth/login', headerOffset = 'auto', developerName }: Props) {
31
+ // Responsive breakpoint detection (mobile if < 768px)
32
+ const [isMobile, setIsMobile] = useState(false);
33
+ useEffect(() => {
34
+ const check = () => setIsMobile(typeof window !== 'undefined' && window.innerWidth < 768);
35
+ check();
36
+ window.addEventListener('resize', check);
37
+ return () => window.removeEventListener('resize', check);
38
+ }, []);
39
+ // Sidebar open state (default expanded on desktop, collapsed on mobile)
40
+ const [open, setOpen] = useState<boolean>(true);
41
+ // Avoid hydration mismatches by rendering only after mount
42
+ const [hasMounted, setHasMounted] = useState(false);
43
+ useEffect(() => { setHasMounted(true); }, []);
44
+ // On first mount, default collapsed on mobile (unless URL prefilled opens it)
45
+ useEffect(() => {
46
+ if (!hasMounted) return;
47
+ try {
48
+ const url = typeof window !== 'undefined' ? new URL(window.location.href) : null;
49
+ const prompt = url ? url.searchParams.get('chatPrompt') : null;
50
+ if (!prompt && isMobile) setOpen(false);
51
+ } catch {}
52
+ // eslint-disable-next-line react-hooks/exhaustive-deps
53
+ }, [hasMounted, isMobile]);
33
54
  // Sidebar width with bounds and persistence
34
55
  const [width, setWidth] = useState<number>(() => {
35
56
  try { const w = Number(localStorage.getItem('kydChatSidebarWidth')); return Number.isFinite(w) && w >= 320 ? Math.min(w, Math.floor(window.innerWidth * 0.9)) : 420; } catch { return 420; }
@@ -100,6 +121,14 @@ export default function ChatWidget({ api = '/api/chat', title = 'KYD Bot', hintT
100
121
  </div>
101
122
  ), [title]);
102
123
 
124
+ // Prevent background scroll on mobile when open fullscreen
125
+ useEffect(() => {
126
+ if (!isMobile) return;
127
+ const original = document.body.style.overflow;
128
+ document.body.style.overflow = open ? 'hidden' : original;
129
+ return () => { document.body.style.overflow = original; };
130
+ }, [isMobile, open]);
131
+
103
132
  // Persist sidebar UI state
104
133
  useEffect(() => {
105
134
  try { localStorage.setItem('kydChatSidebarOpen', open ? '1' : '0'); } catch {}
@@ -199,58 +228,114 @@ export default function ChatWidget({ api = '/api/chat', title = 'KYD Bot', hintT
199
228
  };
200
229
  }, [draggingTab]);
201
230
 
202
- return (
203
- <div className={'fixed z-50'} aria-live={'polite'}>
204
- {/* Collapsed vertical tab (draggable) */}
231
+ const nameForCopy = (developerName && developerName.trim().length > 0) ? developerName : 'this developer';
232
+ const starterQuestions: string[] = [
233
+ `Tell me about ${nameForCopy}'s experience with Python.`,
234
+ `What are ${nameForCopy}'s strongest technical areas?`,
235
+ `Are there any risk or sanctions flags for ${nameForCopy}?`
236
+ ];
237
+
238
+ const onStarterClick = (q: string) => {
239
+ if (!open) setOpen(true);
240
+ if (!sending) {
241
+ // Populate input for UI and send using explicit text to avoid async state issues
242
+ setInput(q);
243
+ sendMessage(q);
244
+ }
245
+ };
246
+
247
+ if (!hasMounted) return null;
248
+
249
+ const ui = (
250
+ <div className={'fixed z-50'} aria-live={'polite'} style={{ zIndex: 9999 }}>
251
+ {/* Collapsed trigger */}
205
252
  {!open && (
206
- <div style={{ position: 'fixed', right: 0, top: Math.max(headerTop + 16, tabTop), height: 160, width: 44 }}>
207
- <button
208
- aria-label={'Open chat sidebar'}
209
- aria-expanded={open}
210
- onMouseDown={(e) => { setDraggingTab(true); dragStartYRef.current = e.clientY; startTopRef.current = tabTop; tabDraggedRef.current = false; }}
211
- onMouseUp={(e) => { e.preventDefault(); const dragged = tabDraggedRef.current; tabDraggedRef.current = false; setDraggingTab(false); if (!dragged) setOpen(true); }}
212
- className={'h-full w-full shadow-lg border rounded-l-lg flex flex-col items-center justify-between transition hover:opacity-90'}
213
- style={{ background: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)', cursor: draggingTab ? 'grabbing' : 'grab' }}
214
- >
215
- <FiMessageSquare size={18} style={{ color: 'var(--text-main)', marginTop: 8 }} />
216
- <span style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)', color: 'var(--text-secondary)', fontSize: 12, letterSpacing: 1 }}>Chat</span>
217
- <FiChevronRight size={16} style={{ color: 'var(--text-secondary)', marginBottom: 8 }} />
218
- </button>
219
- {showHint && (
220
- <button onClick={() => { setOpen(true); onDismissHint(); }} className={'absolute -left-2 -top-8 group max-w-xs text-left'}>
221
- <div className={'px-2 py-1 rounded shadow border'}
222
- style={{ background: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}>
223
- <span className={'text-[10px]'} style={{ color: 'var(--text-main)' }}>{hintText}</span>
224
- </div>
253
+ isMobile ? (
254
+ <div style={{ position: 'fixed', right: 16, bottom: 16, zIndex: 9999 }}>
255
+ <button
256
+ aria-label={'Open chat'}
257
+ aria-expanded={open}
258
+ onClick={() => setOpen(true)}
259
+ className={'shadow-lg border rounded-full flex items-center justify-center transition hover:opacity-90'}
260
+ style={{ width: 56, height: 56, background: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}
261
+ >
262
+ <FiMessageSquare size={22} style={{ color: 'var(--text-main)' }} />
225
263
  </button>
226
- )}
227
- </div>
264
+ {showHint && (
265
+ <button onClick={() => { setOpen(true); onDismissHint(); }} className={'absolute right-full mr-2 bottom-1/2 translate-y-1/2'}>
266
+ <div className={'px-2 py-1 rounded shadow border'}
267
+ style={{ background: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}>
268
+ <span className={'text-[10px]'} style={{ color: 'var(--text-main)' }}>{hintText}</span>
269
+ </div>
270
+ </button>
271
+ )}
272
+ </div>
273
+ ) : (
274
+ <div style={{ position: 'fixed', right: 0, top: Math.max(headerTop + 16, tabTop), height: 160, width: 44 }}>
275
+ <button
276
+ aria-label={'Open chat sidebar'}
277
+ aria-expanded={open}
278
+ onMouseDown={(e) => { setDraggingTab(true); dragStartYRef.current = e.clientY; startTopRef.current = tabTop; tabDraggedRef.current = false; }}
279
+ onMouseUp={(e) => { e.preventDefault(); const dragged = tabDraggedRef.current; tabDraggedRef.current = false; setDraggingTab(false); if (!dragged) setOpen(true); }}
280
+ className={'h-full w-full shadow-lg border rounded-l-lg flex flex-col items-center justify-between transition hover:opacity-90'}
281
+ style={{ background: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)', cursor: draggingTab ? 'grabbing' : 'grab' }}
282
+ >
283
+ <FiMessageSquare size={18} style={{ color: 'var(--text-main)', marginTop: 8 }} />
284
+ <span style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)', color: 'var(--text-secondary)', fontSize: 12, letterSpacing: 1 }}>Chat</span>
285
+ <FiChevronRight size={16} style={{ color: 'var(--text-secondary)', marginBottom: 8 }} />
286
+ </button>
287
+ {showHint && (
288
+ <button onClick={() => { setOpen(true); onDismissHint(); }} className={'absolute -left-2 -top-8 group max-w-xs text-left'}>
289
+ <div className={'px-2 py-1 rounded shadow border'}
290
+ style={{ background: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}>
291
+ <span className={'text-[10px]'} style={{ color: 'var(--text-main)' }}>{hintText}</span>
292
+ </div>
293
+ </button>
294
+ )}
295
+ </div>
296
+ )
228
297
  )}
229
298
 
230
- {/* Expanded right sidebar */}
299
+ {/* Expanded panel */}
231
300
  {open && (
232
301
  <aside
233
302
  role={'complementary'}
234
303
  aria-label={'Chat sidebar'}
235
- style={{ position: 'fixed', top: headerTop, right: 0, bottom: 0, width, maxWidth: '92vw' }}
304
+ style={isMobile ? { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, width: '100vw', zIndex: 10000 } : { position: 'fixed', top: headerTop, right: 0, bottom: 0, width, maxWidth: '92vw', zIndex: 9999 }}
236
305
  className={'shadow-xl border flex flex-col overflow-hidden'}
237
306
  >
238
- {/* Left-edge resizer */}
239
- <div
240
- onMouseDown={(e) => { setDraggingResize(true); dragStartXRef.current = e.clientX; startWidthRef.current = width; }}
241
- aria-hidden={'true'}
242
- style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 6, cursor: 'col-resize', zIndex: 1 }}
243
- />
307
+ {/* Left-edge resizer (desktop only) */}
308
+ {!isMobile && (
309
+ <div
310
+ onMouseDown={(e) => { setDraggingResize(true); dragStartXRef.current = e.clientX; startWidthRef.current = width; }}
311
+ aria-hidden={'true'}
312
+ style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 6, cursor: 'col-resize', zIndex: 1 }}
313
+ />
314
+ )}
244
315
  {/* Panel chrome */}
245
316
  <div style={{ background: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)', height: '100%', display: 'flex', flexDirection: 'column' }} className={'bg-[var(--content-card-background)] border-l'}>
246
317
  {header}
247
318
  <div className={'flex-1 flex flex-col'} style={{ minHeight: 0 }}>
248
319
  <div className={'flex-1'} style={{ minHeight: 0 }}>
249
320
  <MainContainer style={{ height: '100%', position: 'relative', background: 'var(--content-card-background)', border: 'none'}}>
250
- <ChatContainer style={{ height: '100%' }}>
321
+ <ChatContainer style={{ height: '100%', border: 'none'}}>
251
322
  <MessageList typingIndicator={undefined} autoScrollToBottom>
252
323
  {messages.length === 0 && (
253
- <Message model={{ message: 'Start a conversation. I can answer questions about this developer’s report.', sender: 'KYD', direction: 'incoming', position: 'single' }} />
324
+ <div className={'px-3 py-2'}>
325
+ <div className={'text-xs mb-2'} style={{ color: 'var(--text-secondary)' }}>Try asking:</div>
326
+ <div className={'flex flex-wrap gap-2'}>
327
+ {starterQuestions.map((q, i) => (
328
+ <button
329
+ key={i}
330
+ type={'button'}
331
+ onClick={() => onStarterClick(q)}
332
+ className={'px-2 py-2 text-start rounded-lg border text-xs hover:text-[var(--icon-accent)] transition bg-[var(--bubble-background)] text-[var(--bubble-foreground)] border-[var(--icon-button-secondary)]'}
333
+ >
334
+ {q}
335
+ </button>
336
+ ))}
337
+ </div>
338
+ </div>
254
339
  )}
255
340
  {messages
256
341
  .filter(m => !(m.role === 'assistant' && (m.content || '').trim().length === 0))
@@ -299,7 +384,7 @@ export default function ChatWidget({ api = '/api/chat', title = 'KYD Bot', hintT
299
384
  </MainContainer>
300
385
  </div>
301
386
  <form onSubmit={(e) => { e.preventDefault(); if (!sending && input.trim()) sendMessage(); }}>
302
- <div className={'flex items-end gap-2 p-2 border-t'} style={{ borderColor: 'var(--icon-button-secondary)', background: 'var(--content-card-background)' }}>
387
+ <div className={'flex items-end gap-2 p-2 border-t'} style={{ borderColor: 'var(--icon-button-secondary)', background: 'var(--content-card-background)', paddingBottom: isMobile ? 'max(8px, env(safe-area-inset-bottom))' : undefined }}>
303
388
  <input
304
389
  aria-label={'Type your message'}
305
390
  className={'flex-1 px-3 py-2 rounded kyd-chat-input'}
@@ -336,6 +421,8 @@ export default function ChatWidget({ api = '/api/chat', title = 'KYD Bot', hintT
336
421
  )}
337
422
  </div>
338
423
  );
424
+
425
+ return typeof document !== 'undefined' ? createPortal(ui, document.body) : ui;
339
426
  }
340
427
 
341
428
 
@@ -40,6 +40,7 @@ export default function GaugeCard({
40
40
  }) {
41
41
  const pct = Math.max(0, Math.min(100, Math.round(Number(percent ?? 0))));
42
42
  const displayLabel = label || '';
43
+ // Use a fixed internal coordinate system and scale the SVG to container for responsiveness
43
44
  const size = 280;
44
45
  const strokeWidth = 32;
45
46
  const radius = (size - strokeWidth) / 2;
@@ -83,9 +84,9 @@ export default function GaugeCard({
83
84
  </span>
84
85
  )}
85
86
  </div>
86
- <div className="flex-grow flex flex-col items-center justify-center" style={{ minHeight: 250 }}>
87
- <div className="relative group" style={{ width: size, height: size / 2 }}>
88
- <svg width={size} height={size / 2} viewBox={`0 0 ${size} ${size/2}`}>
87
+ <div className="flex-grow flex flex-col items-center justify-center" style={{ minHeight: 200 }}>
88
+ <div className="relative group" style={{ width: '100%', aspectRatio: '2 / 1', maxWidth: 360 }}>
89
+ <svg width={'100%'} height={'100%'} viewBox={`0 0 ${size} ${size/2}`} preserveAspectRatio={'xMidYMid meet'}>
89
90
  <path d={`M ${strokeWidth/2} ${size/2} A ${radius} ${radius} 0 0 1 ${size-strokeWidth/2} ${size/2}`} stroke={'var(--icon-button-secondary)'} strokeWidth={strokeWidth} fill="none" strokeLinecap="round" />
90
91
  <path d={`M ${strokeWidth/2} ${size/2} A ${radius} ${radius} 0 0 1 ${size-strokeWidth/2} ${size/2}`} stroke={progressColor} strokeWidth={strokeWidth} fill="none" strokeLinecap="round" strokeDasharray={`${dash},${circumference}`} />
91
92
  <line x1={size/2} y1={size/2} x2={size/2 + radius * Math.cos(Math.PI * progress - Math.PI)} y2={size/2 + radius * Math.sin(Math.PI * progress - Math.PI)} stroke={'var(--text-main)'} strokeWidth="2" />
@@ -151,9 +151,7 @@ const GraphInsights = ({
151
151
  percentList = Object.entries(scoringSummary.category_scores).map(([cat, entry]) => ({
152
152
  category: cat,
153
153
  // Prefer business, else combined, else atomic percent_progress
154
- percent: Number(
155
- (entry?.business?.percent_progress ?? entry?.combined?.percent_progress ?? entry?.atomic?.percent_progress ?? 0)
156
- ),
154
+ percent: Number(entry?.business?.percent_progress),
157
155
  }));
158
156
  }
159
157
 
@@ -41,19 +41,27 @@ export default function RiskCard({
41
41
  return red;
42
42
  };
43
43
 
44
+ // color bands by bar position (tallest -> lowest)
45
+ // indices 4 and 3 (highest bars) => red, index 2 => yellow, indices 1 and 0 => green
46
+ const colorForIndex = (index: number) => {
47
+ if (index >= 3) return red;
48
+ if (index === 2) return yellow;
49
+ return green;
50
+ };
51
+
44
52
  const headerTint = hexToRgba(pickTint(pctGood), 0.06);
45
53
 
46
54
  // bar heights ascending representation
47
55
  const bars = [40, 60, 85, 110, 140];
48
- let activeIndex = 0; // Default to the shortest bar (highest risk)
56
+ let activeIndex = 4; // Default to the highest bar (0% score)
49
57
  if (pctGood >= 80) {
50
- activeIndex = 4;
58
+ activeIndex = 0;
51
59
  } else if (pctGood >= 60) {
52
- activeIndex = 3;
60
+ activeIndex = 1;
53
61
  } else if (pctGood >= 40) {
54
62
  activeIndex = 2;
55
63
  } else if (pctGood >= 20) {
56
- activeIndex = 1;
64
+ activeIndex = 3;
57
65
  }
58
66
 
59
67
  return (
@@ -88,7 +96,15 @@ export default function RiskCard({
88
96
  <div className="flex flex-col items-center justify-center gap-1" style={{ minHeight: 250 }}>
89
97
  <div className="relative group flex items-end justify-center gap-3">
90
98
  {bars.map((h, i) => (
91
- <div key={i} style={{ width: 36, height: h, backgroundColor: i === activeIndex ? 'var(--text-main)' : 'var(--icon-button-secondary)', borderRadius: 4 }} />
99
+ <div
100
+ key={i}
101
+ style={{
102
+ width: 36,
103
+ height: h,
104
+ backgroundColor: i === activeIndex ? colorForIndex(i) : 'var(--icon-button-secondary)',
105
+ borderRadius: 4,
106
+ }}
107
+ />
92
108
  ))}
93
109
  {(tooltipText || description) && (
94
110
  <div className="hidden group-hover:block absolute z-30 left-1/2 -translate-x-1/2 top-full mt-2 w-80">
@@ -93,7 +93,7 @@ export function buildAllContextPrompt(cleanedData: any, reportGraphData: GraphIn
93
93
  'Prefer concise, direct answers.',
94
94
  'Use ONLY the JSON provided to answer the user.',
95
95
  'Never invent or fetch external data.',
96
- 'If data is missing, say so briefly.',
96
+ 'If data is missing, say so briefly. Assume the question is about the report, and assume the user is asking about the report, do not assume the user is asking you to perform a task other than looking at the report information.',
97
97
  'Return natural language for the main reply.',
98
98
  'When referring to the input JSON, use the term "Report Information" or "Report"',
99
99
  'You are speaking to a non-technical user, ensure you do not use variable-esk language like "based on linkedin_provider", instead use "based the LinkedIn profile"',