kyd-shared-badge 0.3.92 → 0.3.94

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.92",
3
+ "version": "0.3.94",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -21,7 +21,7 @@
21
21
  "@aws-sdk/lib-dynamodb": "^3.893.0",
22
22
  "@chatscope/chat-ui-kit-react": "^2.1.1",
23
23
  "@chatscope/chat-ui-kit-styles": "^1.4.0",
24
- "@knowyourdeveloper/react-bubble-chart": "^1.0.4",
24
+ "@knowyourdeveloper/react-bubble-chart": "^1.0.5",
25
25
  "@radix-ui/react-slot": "^1.2.3",
26
26
  "@radix-ui/react-tooltip": "^1.2.8",
27
27
  "ai": "5.0.47",
@@ -207,80 +207,78 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
207
207
  const OverviewSection = () => (
208
208
  <div className={`${wrapperMaxWidth} mx-auto mt-6`}>
209
209
  <Reveal headless={isHeadless} offsetY={8} durationMs={500}>
210
- <div className={'rounded-xl shadow-xl p-6 sm:p-8 border'} style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}>
211
- {/* Quadrant layout */}
212
- <div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
213
- {/* Top-left: Name, countries, badge image handled by ReportHeader */}
214
- <div>
215
- <ReportHeader
216
- badgeId={badgeId}
217
- developerName={badgeData.developerName}
218
- updatedAt={updatedAt}
219
- score={overallFinalPercent || 0}
220
- isPublic={true}
221
- badgeImageUrl={badgeData.badgeImageUrl || ''}
222
- summary={undefined}
223
- enterpriseMatch={null}
224
- countries={(assessmentResult?.screening_sources?.ip_risk_analysis?.raw_data?.countries) || []}
225
- accountAuthenticity={assessmentResult?.account_authenticity}
226
- companyName={badgeData.companyName}
227
- />
228
- </div>
229
- {/* Top-right: Role match section */}
230
- <div className={'rounded-lg border p-4'} style={{ borderColor: 'var(--icon-button-secondary)' }}>
231
- <div className={'text-lg font-semibold mb-2'} style={{ color: 'var(--text-main)' }}>Role Alignment</div>
232
- {(() => {
233
- const em = assessmentResult?.enterprise_match;
234
- if (!em) return <div className={'text-sm'} style={{ color: 'var(--text-secondary)' }}>No role match available.</div>;
235
- const role = em.role || {};
236
- return (
237
- <div className={'space-y-2'}>
238
- <div className={'flex items-center gap-2'}>
239
- <div className={'font-semibold'} style={{ color: 'var(--text-main)' }}>{role?.name || 'Role'}</div>
240
- <span className={'px-2 py-0.5 rounded text-xs font-semibold'} style={{ backgroundColor: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)' }}>{em.label}</span>
241
- </div>
242
- {em.description ? (
243
- <div className={'text-sm'} style={{ color: 'var(--text-secondary)' }}>{em.description}</div>
244
- ) : null}
210
+ {/* Quadrant layout */}
211
+ <div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
212
+ {/* Top-left: Name, countries, badge image handled by ReportHeader */}
213
+ <div>
214
+ <ReportHeader
215
+ badgeId={badgeId}
216
+ developerName={badgeData.developerName}
217
+ updatedAt={updatedAt}
218
+ score={overallFinalPercent || 0}
219
+ isPublic={true}
220
+ badgeImageUrl={badgeData.badgeImageUrl || ''}
221
+ summary={undefined}
222
+ enterpriseMatch={null}
223
+ countries={(assessmentResult?.screening_sources?.ip_risk_analysis?.raw_data?.countries) || []}
224
+ accountAuthenticity={assessmentResult?.account_authenticity}
225
+ companyName={badgeData.companyName}
226
+ />
227
+ </div>
228
+ {/* Top-right: Role match section */}
229
+ <div className={'rounded-lg border p-4'} style={{ borderColor: 'var(--icon-button-secondary)' }}>
230
+ <div className={'text-lg font-semibold mb-2'} style={{ color: 'var(--text-main)' }}>Role Alignment</div>
231
+ {(() => {
232
+ const em = assessmentResult?.enterprise_match;
233
+ if (!em) return <div className={'text-sm'} style={{ color: 'var(--text-secondary)' }}>No role match available.</div>;
234
+ const role = em.role || {};
235
+ return (
236
+ <div className={'space-y-2'}>
237
+ <div className={'flex items-center gap-2'}>
238
+ <div className={'font-semibold'} style={{ color: 'var(--text-main)' }}>{role?.name || 'Role'}</div>
239
+ <span className={'px-2 py-0.5 rounded text-xs font-semibold'} style={{ backgroundColor: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)' }}>{em.label}</span>
245
240
  </div>
246
- );
247
- })()}
248
- </div>
241
+ {em.description ? (
242
+ <div className={'text-sm'} style={{ color: 'var(--text-secondary)' }}>{em.description}</div>
243
+ ) : null}
244
+ </div>
245
+ );
246
+ })()}
247
+ </div>
249
248
 
250
- {/* Bottom-left: Technical score */}
251
- <div>
252
- {(() => {
253
- const uiTech = graphInsights?.uiSummary?.technical || {};
254
- const techPct = Math.round(Number(uiTech?.percent ?? 0));
255
- const techLabel = uiTech?.label || 'EVIDENCE';
256
- return (
257
- <GaugeCard
258
- title={'KYD Technical'}
259
- description={'Composite of technical evidence; more right indicates stronger capability'}
260
- percent={techPct}
261
- label={techLabel}
262
- topMovers={[]}
263
- />
264
- );
265
- })()}
266
- </div>
267
- {/* Bottom-right: Risk score */}
268
- <div>
269
- {(() => {
270
- const uiRisk = graphInsights?.uiSummary?.risk || {};
271
- const riskPctGood = Math.round(Number(uiRisk?.percent_good ?? 0));
272
- const riskLabel = uiRisk?.label || 'RISK';
273
- return (
274
- <RiskCard
275
- title={'KYD Risk'}
276
- description={'Lower bar height indicates lower risk exposure'}
277
- percentGood={riskPctGood}
278
- label={riskLabel}
279
- topMovers={[]}
280
- />
281
- );
282
- })()}
283
- </div>
249
+ {/* Bottom-left: Technical score */}
250
+ <div>
251
+ {(() => {
252
+ const uiTech = graphInsights?.uiSummary?.technical || {};
253
+ const techPct = Math.round(Number(uiTech?.percent ?? 0));
254
+ const techLabel = uiTech?.label || 'EVIDENCE';
255
+ return (
256
+ <GaugeCard
257
+ title={'KYD Technical'}
258
+ description={'Composite of technical evidence; more right indicates stronger capability'}
259
+ percent={techPct}
260
+ label={techLabel}
261
+ topMovers={[]}
262
+ />
263
+ );
264
+ })()}
265
+ </div>
266
+ {/* Bottom-right: Risk score */}
267
+ <div>
268
+ {(() => {
269
+ const uiRisk = graphInsights?.uiSummary?.risk || {};
270
+ const riskPctGood = Math.round(Number(uiRisk?.percent_good ?? 0));
271
+ const riskLabel = uiRisk?.label || 'RISK';
272
+ return (
273
+ <RiskCard
274
+ title={'KYD Risk'}
275
+ description={'Lower bar height indicates lower risk exposure'}
276
+ percentGood={riskPctGood}
277
+ label={riskLabel}
278
+ topMovers={[]}
279
+ />
280
+ );
281
+ })()}
284
282
  </div>
285
283
  </div>
286
284
  </Reveal>
@@ -3,6 +3,7 @@
3
3
  import React, { useMemo, useRef, useState, useEffect } from 'react';
4
4
  import { BubbleChart } from '@knowyourdeveloper/react-bubble-chart';
5
5
  import '@knowyourdeveloper/react-bubble-chart/style.css';
6
+ import { green1, green2, green3, green4, green5 } from '../colors';
6
7
 
7
8
  type SkillsRadarPoint = {
8
9
  axis: string;
@@ -24,35 +25,44 @@ const TooltipBox = ({ state }: { state: HoverTooltipState }) => {
24
25
  if (!state || !state.visible) return null;
25
26
  return (
26
27
  <div
28
+ className="rounded-md border shadow-sm px-3 py-2 text-xs"
27
29
  style={{
28
30
  position: 'absolute',
29
31
  left: state.x,
30
32
  top: state.y,
31
33
  pointerEvents: 'none',
32
34
  background: 'var(--content-card-background)',
33
- border: '1px solid var(--icon-button-secondary)',
35
+ borderColor: 'var(--icon-button-secondary)',
34
36
  color: 'var(--text-main)',
35
- padding: 10,
36
- borderRadius: 6,
37
+ zIndex: 10,
37
38
  minWidth: 250,
38
39
  maxWidth: 320,
39
- zIndex: 10,
40
40
  }}
41
41
  >
42
- <div style={{ fontWeight: 600 }}>{state.title}</div>
42
+ <div className="font-medium" style={{ color: 'var(--text-main)' }}>{state.title}</div>
43
43
  {state.body ? (
44
- <div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{state.body}</div>
44
+ <div style={{ color: 'var(--text-secondary)' }}>{state.body}</div>
45
45
  ) : null}
46
46
  </div>
47
47
  );
48
48
  };
49
49
 
50
- export default function SkillsBubble({ skillsCategoryRadar, headless }: { skillsCategoryRadar?: SkillsRadarPoint[]; headless?: boolean }) {
50
+ const pickGreenByExperience = (experience: number): string => {
51
+ const exp = Math.max(0, Math.min(100, Number(experience || 0)));
52
+ if (exp >= 80) return green1;
53
+ if (exp >= 60) return green2;
54
+ if (exp >= 40) return green3;
55
+ if (exp >= 20) return green4;
56
+ return green5;
57
+ };
58
+
59
+ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, headless }: { skillsCategoryRadar?: SkillsRadarPoint[]; skillsByCategory?: Record<string, string[]>; headless?: boolean }) {
51
60
  const hasRadar = !!(skillsCategoryRadar && skillsCategoryRadar.length > 0);
52
61
  const skillsRadarLimited = (skillsCategoryRadar || []).slice(0, 24);
53
62
  const containerRef = useRef<HTMLDivElement>(null);
54
63
  const legendRef = useRef<HTMLDivElement>(null);
55
64
  const [legendTooltip, setLegendTooltip] = useState<HoverTooltipState>(null);
65
+ const [activeCategory, setActiveCategory] = useState<string | null>(null);
56
66
 
57
67
  useEffect(() => {
58
68
  if (typeof window !== 'undefined') {
@@ -78,9 +88,7 @@ export default function SkillsBubble({ skillsCategoryRadar, headless }: { skills
78
88
  const value = seriesAvg(d);
79
89
  const experience = Math.max(0, Math.min(100, Number(d.experience || 0)));
80
90
  const size = Math.max(2, Math.round((value / maxValue) * 100)); // 2..100
81
- // map experience to saturation in HSL; keep hue constant for brand-neutral look
82
- const saturation = Math.max(20, Math.min(100, experience));
83
- const color = `hsl(210 ${saturation}% 45%)`;
91
+ const color = 'var(--content-card-background)';
84
92
  return {
85
93
  label: d.axis,
86
94
  value: size,
@@ -101,22 +109,69 @@ export default function SkillsBubble({ skillsCategoryRadar, headless }: { skills
101
109
  }, [bubbles]);
102
110
 
103
111
  const colorLegend = useMemo(() => {
104
- const steps = [20, 35, 50, 65, 80, 90, 100];
105
- return steps.map((s) => `hsl(210 ${s}% 45%)`);
112
+ return [green5, green4, green3, green2, green1];
106
113
  }, []);
107
114
 
108
- const percentLegend = useMemo(() => {
109
- const total = bubbles.reduce((sum, b) => sum + (b.data?.ratio || 0), 0);
110
- return bubbles
111
- .map((b) => ({ label: b.label, percent: total > 0 ? Math.round(((b.data?.ratio || 0) / total) * 100) : 0, experience: b.data?.experience || 0 }))
112
- .sort((a, b) => b.percent - a.percent);
113
- }, [bubbles]);
115
+ const defaultActiveCategory = useMemo(() => {
116
+ let best: { label: string; ratio: number } | null = null;
117
+ for (const b of bubbles) {
118
+ const ratio = Number(b.data?.ratio || 0);
119
+ if (!best || ratio > best.ratio) best = { label: b.label, ratio };
120
+ }
121
+ return best?.label || (skillsRadarLimited[0]?.axis || null);
122
+ }, [bubbles, skillsRadarLimited]);
123
+
124
+ useEffect(() => {
125
+ if (!activeCategory && defaultActiveCategory) setActiveCategory(defaultActiveCategory);
126
+ }, [activeCategory, defaultActiveCategory]);
127
+
128
+ const skillsGrid = useMemo(() => {
129
+ const cat = activeCategory || '';
130
+ const list = (skillsByCategory && cat in (skillsByCategory || {})) ? (skillsByCategory?.[cat] || []) : [];
131
+ const items = list.slice(0, 10);
132
+ const overflow = list.length - items.length;
133
+ const display: string[] = [];
134
+ for (let i = 0; i < Math.min(9, items.length); i++) display.push(items[i]);
135
+ if (list.length > 10) {
136
+ display.push(`Others (${overflow})`);
137
+ } else if (items.length >= 10) {
138
+ display.push(items[9]);
139
+ } else {
140
+ if (items.length > 9) display.push(items[9]);
141
+ }
142
+ while (display.length < 10) display.push('');
143
+ return display;
144
+ }, [activeCategory, skillsByCategory]);
114
145
 
115
146
  if (!hasRadar) return null;
116
147
 
117
148
  return (
118
149
  <div className={'kyd-avoid-break'}>
119
- <div ref={containerRef} style={{ width: '100%', height: 340 }}>
150
+ <div ref={containerRef} style={{ width: '100%', height: 340, position: 'relative' }}>
151
+ {!headless && (
152
+ <div
153
+ className="text-xs rounded-md border shadow-sm px-2 py-1"
154
+ style={{
155
+ position: 'absolute',
156
+ right: 8,
157
+ top: 8,
158
+ zIndex: 5,
159
+ pointerEvents: 'none',
160
+ background: 'var(--content-card-background)',
161
+ borderColor: 'var(--icon-button-secondary)',
162
+ color: 'var(--text-secondary)'
163
+ }}
164
+ >
165
+ <div className="flex items-center gap-2">
166
+ <span className="inline-block h-3 w-3 rounded-full" style={{ background: green1 }} />
167
+ <span>Size = ratio of observed/self-reported/certified</span>
168
+ </div>
169
+ <div className="flex items-center gap-2 mt-1">
170
+ <span className="inline-block h-3 w-3 rounded-full" style={{ background: green5 }} />
171
+ <span>Color = experience (darker = more)</span>
172
+ </div>
173
+ </div>
174
+ )}
120
175
  <BubbleChart
121
176
  className='w-full h-full'
122
177
  data={bubbleData}
@@ -136,71 +191,74 @@ export default function SkillsBubble({ skillsCategoryRadar, headless }: { skills
136
191
  }}
137
192
  tooltipFunc={(node, d) => {
138
193
  try {
194
+ try { setActiveCategory(String(d.displayText || d._id || '')); } catch {}
139
195
  node.innerHTML = '';
196
+ node.className = 'rounded-md border shadow-sm px-3 py-2 text-xs';
197
+ (node as HTMLElement).style.background = 'var(--content-card-background)';
198
+ (node as HTMLElement).style.borderColor = 'var(--icon-button-secondary)';
199
+ (node as HTMLElement).style.color = 'var(--text-main)';
200
+ (node as HTMLElement).style.pointerEvents = 'none';
201
+
202
+ const header = document.createElement('div');
203
+ header.className = 'mb-1';
140
204
  const title = document.createElement('div');
141
- title.style.fontWeight = '600';
205
+ title.className = 'font-medium';
206
+ title.style.color = 'var(--text-main)';
142
207
  title.textContent = String(d.displayText || d._id || '');
143
- const body = document.createElement('div');
144
- body.style.fontSize = '12px';
145
- body.style.color = 'var(--text-secondary)';
208
+ header.appendChild(title);
209
+
210
+ const content = document.createElement('div');
211
+ content.className = 'space-y-1';
146
212
  const ratio = Number(d.value || 0);
147
213
  const experience = Number(d.colorValue || 0);
148
- body.textContent = `${ratio}% ratio • Experience ${experience}`;
149
- node.appendChild(title);
150
- node.appendChild(body);
214
+
215
+ const row1 = document.createElement('div');
216
+ row1.className = 'flex items-center justify-between gap-3';
217
+ const row1Left = document.createElement('span');
218
+ row1Left.style.color = 'var(--text-secondary)';
219
+ row1Left.textContent = 'Ratio';
220
+ const row1Right = document.createElement('span');
221
+ row1Right.className = 'font-medium';
222
+ row1Right.style.color = 'var(--text-main)';
223
+ row1Right.textContent = `${ratio}%`;
224
+ row1.appendChild(row1Left);
225
+ row1.appendChild(row1Right);
226
+
227
+ const row2 = document.createElement('div');
228
+ row2.className = 'flex items-center justify-between gap-3';
229
+ const row2Left = document.createElement('span');
230
+ row2Left.style.color = 'var(--text-secondary)';
231
+ row2Left.textContent = 'Experience';
232
+ const row2Right = document.createElement('span');
233
+ row2Right.className = 'font-medium';
234
+ row2Right.style.color = 'var(--text-main)';
235
+ row2Right.textContent = String(experience);
236
+ row2.appendChild(row2Left);
237
+ row2.appendChild(row2Right);
238
+
239
+ content.appendChild(row1);
240
+ content.appendChild(row2);
241
+
242
+ node.appendChild(header);
243
+ node.appendChild(content);
151
244
  } catch {}
152
245
  }}
153
246
  />
154
247
  </div>
155
248
  <div className={'mt-3'}>
156
249
  <div ref={legendRef} className={'kyd-avoid-break'} style={{ position: 'relative', breakInside: 'avoid', pageBreakInside: 'avoid' as unknown as undefined }}>
157
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
158
- {percentLegend.map((item, idx) => (
159
- <button
160
- key={idx}
161
- className="flex items-center gap-2 text-xs text-left hover:underline underline-offset-2"
162
- style={{ color: 'var(--text-secondary)', background: 'transparent' }}
163
- onClick={() => {
164
- try {
165
- if (typeof window !== 'undefined') {
166
- const anchor = `#appendix-skills-cat-${encodeURIComponent(item.label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''))}`;
167
- const url = `#appendix${anchor}`;
168
- window.location.hash = url;
169
- }
170
- } catch {}
171
- }}
172
- onMouseEnter={(e) => {
173
- const rect = legendRef.current?.getBoundingClientRect();
174
- if (!rect) return;
175
- const x = e.clientX - rect.left + 12;
176
- const y = e.clientY - rect.top + 12;
177
- setLegendTooltip({ visible: true, x, y, title: item.label, body: `${item.label} • ${item.percent}% of ratio • Experience ${item.experience}` });
178
- }}
179
- onMouseMove={(e) => {
180
- if (!legendTooltip || !legendRef.current) return;
181
- const rect = legendRef.current.getBoundingClientRect();
182
- setLegendTooltip({ ...legendTooltip, x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12 });
183
- }}
184
- onMouseLeave={() => setLegendTooltip(null)}
185
- >
186
- <span className={'inline-block h-2 w-2 rounded-full'} style={{ backgroundColor: `hsl(210 ${Math.max(20, Math.min(100, item.experience))}% 45%)`, flexShrink: 0 }} />
187
- <span className="truncate">{item.label}</span>
188
- <span className="ml-auto opacity-80">{item.percent}%</span>
189
- </button>
250
+ <div className="mb-2 text-xs font-medium" style={{ color: 'var(--text-main)' }}>
251
+ {activeCategory ? `Category: ${activeCategory}` : 'Category'}
252
+ </div>
253
+ <div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs" style={{ color: 'var(--text-secondary)', height: 160 }}>
254
+ {skillsGrid.map((label, idx) => (
255
+ <div key={idx} className="flex items-center gap-2 min-w-0">
256
+ <span className={'inline-block h-2 w-2 rounded-full'} style={{ backgroundColor: label ? 'var(--icon-button-secondary)' : 'transparent', flexShrink: 0 }} />
257
+ <span className="truncate" title={label}>{label || '\u00A0'}</span>
258
+ </div>
190
259
  ))}
191
260
  </div>
192
261
  {!headless && <TooltipBox state={legendTooltip} />}
193
- {/* Legends */}
194
- <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs" style={{ color: 'var(--text-secondary)' }}>
195
- <div className="flex items-center gap-2">
196
- <span className="inline-block h-3 w-3 rounded-full" style={{ background: 'hsl(210 80% 45%)' }} />
197
- <span>Bubble size: relative ratio of observed/self-reported/certified</span>
198
- </div>
199
- <div className="flex items-center gap-2">
200
- <span className="inline-block h-3 w-3 rounded-full" style={{ background: 'hsl(210 20% 45%)' }} />
201
- <span>Color saturation: experience (darker = more experienced)</span>
202
- </div>
203
- </div>
204
262
  </div>
205
263
  </div>
206
264
  </div>
package/src/types.ts CHANGED
@@ -381,6 +381,8 @@ export interface GraphInsightsPayload {
381
381
  // New: experience metric (0-100) for color saturation
382
382
  experience?: number;
383
383
  }>;
384
+ // New: mapping of category -> list of skills contributing to that category
385
+ skillsByCategory?: Record<string, string[]>;
384
386
  // New: Flattened list of business rule selections (for appendix)
385
387
  business_rules_all?: Array<{
386
388
  provider: string;