kyd-shared-badge 0.3.93 → 0.3.95

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.93",
3
+ "version": "0.3.95",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -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>
@@ -290,7 +288,7 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
290
288
  <Reveal headless={isHeadless}>
291
289
  <div className={'kyd-avoid-break'}>
292
290
  <h4 className={'text-2xl font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Skills Footprint</h4>
293
- <SkillsBubble skillsCategoryRadar={graphInsights?.skillsCategoryRadar} headless={isHeadless} />
291
+ <SkillsBubble skillsCategoryRadar={graphInsights?.skillsCategoryRadar} headless={isHeadless} skillsByCategory={assessmentResult?.graph_insights?.skillsByCategory} />
294
292
  </div>
295
293
  </Reveal>
296
294
  <div className={'pt-6 text-sm text-center'} style={{ color: 'var(--text-secondary)' }}>
@@ -56,13 +56,15 @@ const pickGreenByExperience = (experience: number): string => {
56
56
  return green5;
57
57
  };
58
58
 
59
- export default function SkillsBubble({ skillsCategoryRadar, headless }: { skillsCategoryRadar?: SkillsRadarPoint[]; headless?: boolean }) {
59
+ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, skillsMeta, headless }: { skillsCategoryRadar?: SkillsRadarPoint[]; skillsByCategory?: Record<string, string[]>; skillsMeta?: Record<string, { presence?: 'certified' | 'observed' | 'self-reported'; years?: number }>; headless?: boolean }) {
60
60
  const hasRadar = !!(skillsCategoryRadar && skillsCategoryRadar.length > 0);
61
61
  const skillsRadarLimited = (skillsCategoryRadar || []).slice(0, 24);
62
62
  const containerRef = useRef<HTMLDivElement>(null);
63
63
  const legendRef = useRef<HTMLDivElement>(null);
64
64
  const [legendTooltip, setLegendTooltip] = useState<HoverTooltipState>(null);
65
-
65
+ const [activeCategory, setActiveCategory] = useState<string | null>(null);
66
+ console.log('skillsRadarLimited', skillsRadarLimited);
67
+ console.log('skillsByCategory', skillsByCategory);
66
68
  useEffect(() => {
67
69
  if (typeof window !== 'undefined') {
68
70
  const id = window.setTimeout(() => {
@@ -111,18 +113,90 @@ export default function SkillsBubble({ skillsCategoryRadar, headless }: { skills
111
113
  return [green5, green4, green3, green2, green1];
112
114
  }, []);
113
115
 
114
- const percentLegend = useMemo(() => {
116
+ const defaultActiveCategory = useMemo(() => {
117
+ let best: { label: string; ratio: number } | null = null;
118
+ for (const b of bubbles) {
119
+ const ratio = Number(b.data?.ratio || 0);
120
+ if (!best || ratio > best.ratio) best = { label: b.label, ratio };
121
+ }
122
+ return best?.label || (skillsRadarLimited[0]?.axis || null);
123
+ }, [bubbles, skillsRadarLimited]);
124
+
125
+ useEffect(() => {
126
+ if (!activeCategory && defaultActiveCategory) setActiveCategory(defaultActiveCategory);
127
+ }, [activeCategory, defaultActiveCategory]);
128
+
129
+ const skillsGrid = useMemo(() => {
130
+ const cat = activeCategory || '';
131
+ const list = (skillsByCategory && cat in (skillsByCategory || {})) ? (skillsByCategory?.[cat] || []) : [];
132
+ // Enrich with meta and sort by years desc
133
+ const enriched = list.map((name) => {
134
+ const meta = skillsMeta?.[name] || {};
135
+ return { name, years: Number(meta.years || 0), presence: (meta.presence as string) || '' };
136
+ }).sort((a, b) => b.years - a.years || a.name.localeCompare(b.name));
137
+ const items = enriched.slice(0, 10);
138
+ const overflow = list.length - items.length;
139
+ const display: Array<{ label: string; years?: number; presence?: string } | ''> = [];
140
+ for (let i = 0; i < Math.min(9, items.length); i++) {
141
+ const it = items[i];
142
+ const label = it.name ? `${it.name}` : '';
143
+ display.push({ label, years: it.years, presence: it.presence });
144
+ }
145
+ if (list.length > 10) {
146
+ display.push({ label: `Others (${overflow})` });
147
+ } else if (items.length >= 10) {
148
+ const it = items[9];
149
+ display.push({ label: it?.name || '', years: it?.years, presence: it?.presence });
150
+ } else {
151
+ if (items.length > 9) {
152
+ const it = items[9];
153
+ display.push({ label: it?.name || '', years: it?.years, presence: it?.presence });
154
+ }
155
+ }
156
+ while (display.length < 10) display.push('');
157
+ return display;
158
+ }, [activeCategory, skillsByCategory, skillsMeta]);
159
+
160
+ // Compute category percent share (by ratio) for header display
161
+ const categoryPercentMap = useMemo(() => {
115
162
  const total = bubbles.reduce((sum, b) => sum + (b.data?.ratio || 0), 0);
116
- return bubbles
117
- .map((b) => ({ label: b.label, percent: total > 0 ? Math.round(((b.data?.ratio || 0) / total) * 100) : 0, experience: b.data?.experience || 0 }))
118
- .sort((a, b) => b.percent - a.percent);
163
+ const map: Record<string, number> = {};
164
+ for (const b of bubbles) {
165
+ const p = total > 0 ? Math.round(((b.data?.ratio || 0) / total) * 100) : 0;
166
+ map[b.label] = p;
167
+ }
168
+ return map;
119
169
  }, [bubbles]);
120
170
 
121
171
  if (!hasRadar) return null;
122
172
 
123
173
  return (
124
174
  <div className={'kyd-avoid-break'}>
125
- <div ref={containerRef} style={{ width: '100%', height: 340 }}>
175
+ <div ref={containerRef} style={{ width: '100%', height: 340, position: 'relative' }}>
176
+ {!headless && (
177
+ <div
178
+ className="text-xs rounded-md border shadow-sm px-2 py-1"
179
+ style={{
180
+ position: 'absolute',
181
+ right: 8,
182
+ top: 8,
183
+ zIndex: 5,
184
+ pointerEvents: 'none',
185
+ background: 'var(--content-card-background)',
186
+ borderColor: 'var(--icon-button-secondary)',
187
+ color: 'var(--text-secondary)'
188
+ }}
189
+ >
190
+ <div className="flex items-center gap-2">
191
+ <span className="inline-block h-3 w-3 rounded-full" style={{ background: green1 }} />
192
+ <span>Size = ratio of observed/self-reported/certified</span>
193
+ </div>
194
+ <div className="flex items-center gap-2 mt-1">
195
+ <span className="inline-block h-3 w-3 rounded-full" style={{ background: green5 }} />
196
+ <span>Color = experience (darker = more)</span>
197
+ </div>
198
+ </div>
199
+ )}
126
200
  <BubbleChart
127
201
  className='w-full h-full'
128
202
  data={bubbleData}
@@ -142,6 +216,7 @@ export default function SkillsBubble({ skillsCategoryRadar, headless }: { skills
142
216
  }}
143
217
  tooltipFunc={(node, d) => {
144
218
  try {
219
+ try { setActiveCategory(String(d.displayText || d._id || '')); } catch {}
145
220
  node.innerHTML = '';
146
221
  node.className = 'rounded-md border shadow-sm px-3 py-2 text-xs';
147
222
  (node as HTMLElement).style.background = 'var(--content-card-background)';
@@ -149,101 +224,39 @@ export default function SkillsBubble({ skillsCategoryRadar, headless }: { skills
149
224
  (node as HTMLElement).style.color = 'var(--text-main)';
150
225
  (node as HTMLElement).style.pointerEvents = 'none';
151
226
 
152
- const header = document.createElement('div');
153
- header.className = 'mb-1';
154
227
  const title = document.createElement('div');
155
228
  title.className = 'font-medium';
156
229
  title.style.color = 'var(--text-main)';
157
230
  title.textContent = String(d.displayText || d._id || '');
158
- header.appendChild(title);
159
-
160
- const content = document.createElement('div');
161
- content.className = 'space-y-1';
162
- const ratio = Number(d.value || 0);
163
- const experience = Number(d.colorValue || 0);
164
-
165
- const row1 = document.createElement('div');
166
- row1.className = 'flex items-center justify-between gap-3';
167
- const row1Left = document.createElement('span');
168
- row1Left.style.color = 'var(--text-secondary)';
169
- row1Left.textContent = 'Ratio';
170
- const row1Right = document.createElement('span');
171
- row1Right.className = 'font-medium';
172
- row1Right.style.color = 'var(--text-main)';
173
- row1Right.textContent = `${ratio}%`;
174
- row1.appendChild(row1Left);
175
- row1.appendChild(row1Right);
176
-
177
- const row2 = document.createElement('div');
178
- row2.className = 'flex items-center justify-between gap-3';
179
- const row2Left = document.createElement('span');
180
- row2Left.style.color = 'var(--text-secondary)';
181
- row2Left.textContent = 'Experience';
182
- const row2Right = document.createElement('span');
183
- row2Right.className = 'font-medium';
184
- row2Right.style.color = 'var(--text-main)';
185
- row2Right.textContent = String(experience);
186
- row2.appendChild(row2Left);
187
- row2.appendChild(row2Right);
188
-
189
- content.appendChild(row1);
190
- content.appendChild(row2);
191
-
192
- node.appendChild(header);
193
- node.appendChild(content);
231
+ node.appendChild(title);
194
232
  } catch {}
195
233
  }}
196
234
  />
197
235
  </div>
198
236
  <div className={'mt-3'}>
199
237
  <div ref={legendRef} className={'kyd-avoid-break'} style={{ position: 'relative', breakInside: 'avoid', pageBreakInside: 'avoid' as unknown as undefined }}>
200
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
201
- {percentLegend.map((item, idx) => (
202
- <button
203
- key={idx}
204
- className="flex items-center gap-2 text-xs text-left hover:underline underline-offset-2"
205
- style={{ color: 'var(--text-secondary)', background: 'transparent' }}
206
- onClick={() => {
207
- try {
208
- if (typeof window !== 'undefined') {
209
- const anchor = `#appendix-skills-cat-${encodeURIComponent(item.label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''))}`;
210
- const url = `#appendix${anchor}`;
211
- window.location.hash = url;
212
- }
213
- } catch {}
214
- }}
215
- onMouseEnter={(e) => {
216
- const rect = legendRef.current?.getBoundingClientRect();
217
- if (!rect) return;
218
- const x = e.clientX - rect.left + 12;
219
- const y = e.clientY - rect.top + 12;
220
- setLegendTooltip({ visible: true, x, y, title: item.label, body: `${item.label} • ${item.percent}% of ratio • Experience ${item.experience}` });
221
- }}
222
- onMouseMove={(e) => {
223
- if (!legendTooltip || !legendRef.current) return;
224
- const rect = legendRef.current.getBoundingClientRect();
225
- setLegendTooltip({ ...legendTooltip, x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12 });
226
- }}
227
- onMouseLeave={() => setLegendTooltip(null)}
228
- >
229
- <span className={'inline-block h-2 w-2 rounded-full'} style={{ backgroundColor: pickGreenByExperience(item.experience), flexShrink: 0 }} />
230
- <span className="truncate">{item.label}</span>
231
- <span className="ml-auto opacity-80">{item.percent}%</span>
232
- </button>
238
+ <div className="mb-2 text-xs font-medium" style={{ color: 'var(--text-main)' }}>
239
+ {activeCategory ? `Category: ${activeCategory}` : 'Category'}
240
+ {activeCategory ? ` • ${categoryPercentMap[activeCategory] ?? 0}%` : ''}
241
+ </div>
242
+ <div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs" style={{ color: 'var(--text-secondary)', height: 160 }}>
243
+ {skillsGrid.map((entry, idx) => (
244
+ <div key={idx} className="flex items-center gap-2 min-w-0">
245
+ <span className={'inline-block h-2 w-2 rounded-full'} style={{ backgroundColor: entry ? 'var(--icon-button-secondary)' : 'transparent', flexShrink: 0 }} />
246
+ <span className="shrink-0 opacity-70">{idx + 1}.</span>
247
+ {entry && typeof entry !== 'string' ? (
248
+ <span className="truncate" title={entry.label}>
249
+ {entry.label}
250
+ {entry.years ? ` • ${entry.years}y` : ''}
251
+ {entry.presence ? ` • ${entry.presence}` : ''}
252
+ </span>
253
+ ) : (
254
+ <span className="truncate">{typeof entry === 'string' ? entry : '\u00A0'}</span>
255
+ )}
256
+ </div>
233
257
  ))}
234
258
  </div>
235
259
  {!headless && <TooltipBox state={legendTooltip} />}
236
- {/* Legends */}
237
- <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs" style={{ color: 'var(--text-secondary)' }}>
238
- <div className="flex items-center gap-2">
239
- <span className="inline-block h-3 w-3 rounded-full" style={{ background: green1 }} />
240
- <span>Bubble size: relative ratio of observed/self-reported/certified</span>
241
- </div>
242
- <div className="flex items-center gap-2">
243
- <span className="inline-block h-3 w-3 rounded-full" style={{ background: green5 }} />
244
- <span>Color shade: experience (darker = more experienced)</span>
245
- </div>
246
- </div>
247
260
  </div>
248
261
  </div>
249
262
  </div>
package/src/types.ts CHANGED
@@ -381,6 +381,10 @@ 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[]>;
386
+ // New: per-skill metadata used by UI (e.g., presence label, experience years)
387
+ skillsMeta?: Record<string, { presence?: 'certified' | 'observed' | 'self-reported'; years?: number }>;
384
388
  // New: Flattened list of business rule selections (for appendix)
385
389
  business_rules_all?: Array<{
386
390
  provider: string;