kyd-shared-badge 0.3.99 → 0.3.101

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.99",
3
+ "version": "0.3.101",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -211,7 +211,7 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
211
211
  {/* Quadrant layout */}
212
212
  <div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
213
213
  {/* Top-left: Name, countries, badge image handled by ReportHeader */}
214
- <div>
214
+ <div className={`${hasEnterpriseMatch ? '' : 'md:col-span-2'}`}>
215
215
  <ReportHeader
216
216
  badgeId={badgeId}
217
217
  developerName={badgeData.developerName}
@@ -227,20 +227,22 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
227
227
  />
228
228
  </div>
229
229
  {/* Top-right: Role match section */}
230
- <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
- <RoleOverviewCard
237
- title={'Role Alignment'}
238
- matchLabel={em.label}
239
- roleName={role?.name || 'Role'}
240
- />
241
- );
242
- })()}
243
- </div>
230
+ {hasEnterpriseMatch && (
231
+ <div>
232
+ {(() => {
233
+ const em = assessmentResult?.enterprise_match;
234
+ if (!em) return null;
235
+ const role = em.role || {};
236
+ return (
237
+ <RoleOverviewCard
238
+ title={'Role Alignment'}
239
+ matchLabel={em.label}
240
+ roleName={role?.name || 'Role'}
241
+ />
242
+ );
243
+ })()}
244
+ </div>
245
+ )}
244
246
 
245
247
  {/* Bottom-left: Technical score */}
246
248
  <div>
@@ -1,10 +1,10 @@
1
1
  'use client';
2
2
 
3
- import React from 'react';
3
+ import React, { useMemo } from 'react';
4
4
  import GaugeComponent from '@knowyourdeveloper/react-gauge-component';
5
5
  import BusinessRuleLink from './BusinessRuleLink';
6
6
  import { FiInfo } from 'react-icons/fi';
7
- import { hexToRgba, scoreToColorHex, scoreToCssVar, clampPercent, red, yellow, green } from '../colors';
7
+ import { hexToRgba, scoreToColorHex, clampPercent, red, yellow, green } from '../colors';
8
8
 
9
9
  type TopMover = { label?: string; uid?: string };
10
10
 
@@ -29,20 +29,43 @@ export default function GaugeCard({
29
29
  }) {
30
30
  const pct = clampPercent(percent);
31
31
  const displayLabel = label || '';
32
- // Keep card tinting consistent with score
33
- const progressColor = scoreToCssVar(pct);
34
32
  const headerTint = hexToRgba(scoreToColorHex(pct), 0.06);
35
33
 
34
+ // Technical evidence tiers from backend thresholds
35
+ const tickLabels = useMemo(() => ({
36
+ type: 'outer' as const,
37
+ hideMinMax: false,
38
+ defaultTickLineConfig: {
39
+ length: 7,
40
+ width: 1,
41
+ distanceFromArc: 3,
42
+ color: 'var(--icon-button-secondary)'
43
+ },
44
+ defaultTickValueConfig: {
45
+ style: {
46
+ fontSize: '10px',
47
+ fill: 'var(--text-secondary)'
48
+ }
49
+ },
50
+ ticks: [
51
+ { value: 0, valueConfig: { formatTextValue: () => 'Very Low' } },
52
+ { value: 25, valueConfig: { formatTextValue: () => 'Low' } },
53
+ { value: 50, valueConfig: { formatTextValue: () => 'Moderate' } },
54
+ { value: 75, valueConfig: { formatTextValue: () => 'High' } },
55
+ { value: 100, valueConfig: { formatTextValue: () => 'Very High' } },
56
+ ]
57
+ }), []);
58
+
36
59
  return (
37
60
  <div
38
- className={'rounded-md p-5 border flex flex-col min-h-full'}
61
+ className={'rounded-md border flex flex-col min-h-full'}
39
62
  style={{
40
63
  backgroundColor: 'var(--content-card-background)',
41
64
  borderColor: 'var(--icon-button-secondary)',
42
65
  backgroundImage: `linear-gradient(${headerTint}, ${headerTint})`,
43
66
  }}
44
67
  >
45
- <div className="mb-3 flex items-start justify-between gap-2">
68
+ <div className="mb-3 flex items-start justify-between gap-2 pt-5 px-5">
46
69
  <div>
47
70
  <div className={'font-semibold'} style={{ color: 'var(--text-main)' }}>{title}</div>
48
71
  {description ? (
@@ -63,19 +86,21 @@ export default function GaugeCard({
63
86
  )}
64
87
  </div>
65
88
  <div className="flex-grow flex flex-col items-center justify-center" style={{ minHeight: 200 }}>
66
- <div className="relative group" style={{ width: '100%', aspectRatio: '2 / 1', maxWidth: 360 }}>
89
+ <div className="relative group" style={{ width: '100%', aspectRatio: '2 / 1', maxWidth: 390 }}>
67
90
  <GaugeComponent
68
91
  type="semicircle"
69
- style={{ width: '100%', height: '100%' }}
92
+ style={{ width: 'calc(100% - 16px)', height: '100%', marginLeft: 8, marginRight: 8 }}
93
+ marginInPercent={{ top: 0.08, bottom: 0.0, left: 0.1, right: 0.1 }}
70
94
  value={pct}
71
95
  minValue={0}
72
96
  maxValue={100}
73
97
  labels={{
74
98
  valueLabel: {
75
- // Show backend-provided label in the center instead of percent
76
- formatTextValue: () => displayLabel,
99
+ // Hide center text; show tier labels around the arc instead
100
+ formatTextValue: () => '',
77
101
  matchColorWithArc: true,
78
102
  },
103
+ tickLabels: tickLabels,
79
104
  }}
80
105
  arc={{
81
106
  padding: 0.02,
@@ -98,7 +123,6 @@ export default function GaugeCard({
98
123
  </div>
99
124
  )}
100
125
  </div>
101
- <div className={'mt-3 text-sm font-semibold'} style={{ color: 'var(--text-main)' }}>{displayLabel}</div>
102
126
  </div>
103
127
  {Array.isArray(topMovers) && topMovers.length > 0 && (
104
128
  <div className="mt-4 text-center">
@@ -51,7 +51,7 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
51
51
 
52
52
  return (
53
53
  <div
54
- className={'mb-8 p-6 rounded-xl shadow-lg border'}
54
+ className={'p-6 rounded-md shadow-md border'}
55
55
  style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)', backgroundImage: `linear-gradient(${tint}, ${tint})` }}
56
56
  >
57
57
  {(() => {
@@ -73,14 +73,18 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
73
73
  </div>
74
74
  );
75
75
  })()}
76
- <div className="flex flex-col gap-6">
76
+ <div className="flex flex-col gap-3">
77
77
  {/* Info section: Title, Candidate, Details and Summary */}
78
78
  <div className="w-full">
79
79
  <div className='space-y-2'>
80
80
  <span className='flex gap-2 w-full items-end text-start justify-start'>
81
- <h2 className={'text-xl font-light'} style={{ color: 'var(--text-main)' }}>KYD Candidate Report:</h2>
81
+ <h2 className={'text-xl font-light'} style={{ color: 'var(--text-main)' }}>KYD Candidate:</h2>
82
82
  <div className={'text-xl font-bold'} style={{ color: 'var(--text-main)' }}>{developerName || 'N/A'}</div>
83
83
  </span>
84
+ <span className='flex gap-2 w-full items-end text-start justify-start'>
85
+ <h3 className='text-sm font-semibold text-[--text-secondary]'>Date:</h3>
86
+ <span className='text-sm text-[--text-main]'>{formatLocalDate(updatedAt)}</span>
87
+ </span>
84
88
  <div className={'text-sm'}>
85
89
  {Array.isArray(countries) && countries.length > 0 && (
86
90
  (() => {
@@ -88,9 +92,21 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
88
92
  .map(code => countriesLib.getName((code || '').toUpperCase(), 'en') || code)
89
93
  .filter(Boolean);
90
94
  return (
91
- <p>
92
- <span className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>Observed Country Affiliations:</span>{' '}
95
+ <p className={'flex items-center gap-1'}>
96
+ <span className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>
97
+ {countryNames.length > 1 ? 'Countries:' : 'Country:'}
98
+ </span>
99
+
93
100
  <span style={{ color: 'var(--text-main)' }}>{countryNames.join(', ')}</span>
101
+ <span className={'relative inline-flex items-center group cursor-help'} style={{ color: 'var(--text-secondary)' }}>
102
+ <FiInfo size={14} />
103
+ <div className="hidden group-hover:block absolute z-30 left-1/2 -translate-x-1/2 top-full mt-2 w-80">
104
+ <div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6 }}>
105
+ <div style={{ fontWeight: 600 }}>Countries</div>
106
+ <div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-secondary)' }}>Derived from linked sources and activity; shows where they’re most affiliated.</div>
107
+ </div>
108
+ </div>
109
+ </span>
94
110
  </p>
95
111
  );
96
112
  })()
@@ -135,7 +151,7 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
135
151
  {/* Badge Image with robust centered overlay */}
136
152
  <div className="w-full flex items-center justify-center self-stretch">
137
153
  <div className="relative w-full max-w-xs select-none">
138
- <Image src={finalBadgeImageUrl} alt="KYD Badge" width={400} height={400} priority className='w-full h-auto pointer-events-none p-10'/>
154
+ <Image src={finalBadgeImageUrl} alt="KYD Badge" width={100} height={100} priority className='w-full h-auto pointer-events-none p-10'/>
139
155
  <div className="pointer-events-none absolute left-1/2 top-[66%] -translate-x-1/2 -translate-y-1/2">
140
156
  <div className="font-extrabold text-black text-3xl ">
141
157
  {Math.round(score || 0)}%
@@ -89,6 +89,18 @@ export default function RiskCard({
89
89
  }}
90
90
  />
91
91
  ))}
92
+ <span
93
+ className="pointer-events-none absolute left-[-65px] bottom-0 text-xs"
94
+ style={{ color: 'var(--text-secondary)' }}
95
+ >
96
+ Low risk -
97
+ </span>
98
+ <span
99
+ className="pointer-events-none absolute right-[-68px] top-0 text-xs"
100
+ style={{ color: 'var(--text-secondary)' }}
101
+ >
102
+ - High Risk
103
+ </span>
92
104
  {(tooltipText || description) && (
93
105
  <div className="hidden group-hover:block absolute z-30 left-1/2 -translate-x-1/2 top-full mt-2 w-80">
94
106
  <div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6 }}>
@@ -1,6 +1,8 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useMemo } from 'react';
4
+ import Link from 'next/link';
5
+ import { FiInfo } from 'react-icons/fi';
4
6
  import GaugeComponent from '@knowyourdeveloper/react-gauge-component';
5
7
  import { clampPercent, hexToRgba, scoreToColorHex, red, yellow, green } from '../colors';
6
8
 
@@ -27,17 +29,33 @@ export default function RoleOverviewCard({
27
29
  const headerTint = hexToRgba(scoreToColorHex(pct), 0.06);
28
30
  const displayLabel = String(matchLabel || '').trim();
29
31
 
30
- const segmentLabels = [
31
- 'Incompatible',
32
- 'Weak',
33
- 'Partial',
34
- 'Strong',
35
- 'Optimal',
36
- ];
32
+ const tickLabels = useMemo(() => ({
33
+ type: 'outer' as const,
34
+ hideMinMax: false,
35
+ defaultTickLineConfig: {
36
+ length: 7,
37
+ width: 1,
38
+ distanceFromArc: 3,
39
+ color: 'var(--icon-button-secondary)'
40
+ },
41
+ defaultTickValueConfig: {
42
+ style: {
43
+ fontSize: '10px',
44
+ fill: 'var(--text-secondary)'
45
+ }
46
+ },
47
+ ticks: [
48
+ { value: 0, valueConfig: { formatTextValue: () => 'Incompatible' } },
49
+ { value: 25, valueConfig: { formatTextValue: () => 'Weak' } },
50
+ { value: 50, valueConfig: { formatTextValue: () => 'Partial' } },
51
+ { value: 75, valueConfig: { formatTextValue: () => 'Strong' } },
52
+ { value: 100, valueConfig: { formatTextValue: () => 'Optimal' } },
53
+ ]
54
+ }), []);
37
55
 
38
56
  return (
39
57
  <div
40
- className={'rounded-md p-5 border flex flex-col min-h-full'}
58
+ className={'rounded-md p-5 border flex flex-col h-full'}
41
59
  style={{
42
60
  backgroundColor: 'var(--content-card-background)',
43
61
  borderColor: 'var(--icon-button-secondary)',
@@ -47,16 +65,24 @@ export default function RoleOverviewCard({
47
65
  <div className="mb-3 flex items-start justify-between gap-2">
48
66
  <div>
49
67
  <div className={'font-semibold'} style={{ color: 'var(--text-main)' }}>{title}</div>
50
- {roleName ? (
51
- <div className={'text-xs mt-1'} style={{ color: 'var(--text-secondary)' }}>{roleName}</div>
52
- ) : null}
68
+ <div className={'text-xs mt-1'} style={{ color: 'var(--text-secondary)' }}>How well the candidate aligns with the target role based on KYD evidence.</div>
53
69
  </div>
70
+ <span className={'relative inline-flex items-center group cursor-help'} style={{ color: 'var(--text-secondary)' }}>
71
+ <FiInfo />
72
+ <div className="hidden group-hover:block absolute z-30 right-0 top-full mt-2 w-80">
73
+ <div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6 }}>
74
+ <div style={{ fontWeight: 600 }}>{title}</div>
75
+ <div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>Alignment reflects how well the candidate matches the target role based on KYD evidence.</div>
76
+ </div>
77
+ </div>
78
+ </span>
54
79
  </div>
55
80
  <div className="flex-grow flex flex-col items-center justify-center" style={{ minHeight: 200 }}>
56
81
  <div className="relative" style={{ width: '100%', aspectRatio: '2 / 1', maxWidth: 360 }}>
57
82
  <GaugeComponent
58
83
  type="semicircle"
59
- style={{ width: '100%', height: '100%' }}
84
+ style={{ width: 'calc(100% - 16px)', height: '100%', marginLeft: 8, marginRight: 8 }}
85
+ marginInPercent={{ top: 0.08, bottom: 0.0, left: 0.1, right: 0.1 }}
60
86
  value={pct}
61
87
  minValue={0}
62
88
  maxValue={100}
@@ -65,6 +91,7 @@ export default function RoleOverviewCard({
65
91
  formatTextValue: () => displayLabel,
66
92
  matchColorWithArc: true,
67
93
  },
94
+ tickLabels: tickLabels,
68
95
  }}
69
96
  arc={{
70
97
  padding: 0.02,
@@ -74,17 +101,13 @@ export default function RoleOverviewCard({
74
101
  pointer={{ type: 'arrow', elastic: true, animationDelay: 0 }}
75
102
  />
76
103
  </div>
77
- <div className="mt-3 w-full">
78
- <div className="grid grid-cols-5 text-[10px] sm:text-xs" style={{ color: 'var(--text-secondary)' }}>
79
- {segmentLabels.map((label, idx) => (
80
- <div key={idx} className="flex flex-col items-center">
81
- <div className="h-2 w-px" style={{ background: 'var(--icon-button-secondary)' }} />
82
- <div className="mt-1 whitespace-nowrap">{label}</div>
83
- </div>
84
- ))}
85
- </div>
104
+ {roleName ? (
105
+ <div className="mt-3 text-center">
106
+ <Link href="#role" className={'text-sm font-semibold text-[var(--text-main)] hover:underline'}>{roleName}</Link>
86
107
  </div>
108
+ ) : null}
87
109
  </div>
110
+
88
111
  </div>
89
112
  );
90
113
  }
@@ -13,6 +13,8 @@ type SkillsRadarPoint = {
13
13
  self_reported?: number;
14
14
  certified?: number;
15
15
  experience?: number; // 0-100 saturation driver
16
+ // Total evidence count (backend computed) per category
17
+ evidence_count_total?: number;
16
18
  };
17
19
 
18
20
  type HoverTooltipState = {
@@ -20,7 +22,7 @@ type HoverTooltipState = {
20
22
  x: number;
21
23
  y: number;
22
24
  title: string;
23
- body?: string;
25
+ body?: React.ReactNode;
24
26
  } | null;
25
27
 
26
28
  const TooltipBox = ({ state }: { state: HoverTooltipState }) => {
@@ -50,7 +52,7 @@ const TooltipBox = ({ state }: { state: HoverTooltipState }) => {
50
52
  };
51
53
 
52
54
 
53
- export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, skillsMeta, headless }: { skillsCategoryRadar?: SkillsRadarPoint[]; skillsByCategory?: Record<string, string[]>; skillsMeta?: Record<string, { presence?: 'certified' | 'observed' | 'self-reported'; years?: number; sources?: string[] }>; headless?: boolean }) {
55
+ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, skillsMeta, headless }: { skillsCategoryRadar?: SkillsRadarPoint[]; skillsByCategory?: Record<string, string[]>; skillsMeta?: Record<string, { presence?: 'certified' | 'observed' | 'self-reported'; presenceTypes?: Array<'certified' | 'observed' | 'self-reported'>; years?: number; sources?: string[]; evidenceCount?: number }>; headless?: boolean }) {
54
56
  const hasRadar = !!(skillsCategoryRadar && skillsCategoryRadar.length > 0);
55
57
  const skillsRadarLimited = (skillsCategoryRadar || []).slice(0, 24);
56
58
  const containerRef = useRef<HTMLDivElement>(null);
@@ -58,6 +60,7 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
58
60
  const [legendTooltip, setLegendTooltip] = useState<HoverTooltipState>(null);
59
61
  const [activeCategory, setActiveCategory] = useState<string | null>(null);
60
62
  const [legendHovered, setLegendHovered] = useState<boolean>(false);
63
+
61
64
  useEffect(() => {
62
65
  if (typeof window !== 'undefined') {
63
66
  const id = window.setTimeout(() => {
@@ -77,14 +80,14 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
77
80
  const y = rect.bottom - hostRect.top + 6;
78
81
  return { x, y };
79
82
  };
80
- const showLegendTooltipAt = (target: EventTarget | null, title: string, body?: string) => {
83
+ const showLegendTooltipAt = (target: EventTarget | null, title: string, body?: React.ReactNode) => {
81
84
  if (!(target instanceof HTMLElement)) return;
82
85
  const { x, y } = computeTooltipPosition(target);
83
86
  setLegendTooltip({ visible: true, x, y, title, body });
84
87
  };
85
88
  const hideLegendTooltip = () => setLegendTooltip(null);
86
89
 
87
- // ratio drives size: average of observed/self_reported/certified
90
+ // ratio drives size by default; prefer backend evidence_count_total when present
88
91
  const bubbles = useMemo(() => {
89
92
  const seriesAvg = (d: SkillsRadarPoint): number => {
90
93
  const vals = [Number(d.observed || 0), Number(d.self_reported || 0), Number(d.certified || 0)];
@@ -93,10 +96,19 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
93
96
  return Math.max(0, Math.min(100, Math.round(base.reduce((a, b) => a + b, 0) / (base.length || 1))));
94
97
  };
95
98
 
96
- const maxValue = Math.max(1, ...skillsRadarLimited.map(seriesAvg));
99
+ // Prefer evidence_count_total across categories if available; fallback to ratio
100
+ const evidenceTotals = (skillsCategoryRadar || []).map((d) => Number((d as any).evidence_count_total || 0));
101
+ const evidenceAvailable = evidenceTotals.some((v) => v > 0);
102
+
103
+ const maxValue = evidenceAvailable
104
+ ? Math.max(1, ...evidenceTotals)
105
+ : Math.max(1, ...skillsRadarLimited.map(seriesAvg));
97
106
 
98
107
  return skillsRadarLimited.map((d) => {
99
- const value = seriesAvg(d);
108
+ const ratio = seriesAvg(d);
109
+ const evidenceCountTotal = Number((d as any).evidence_count_total || 0);
110
+ const valueRaw = evidenceAvailable ? evidenceCountTotal : ratio;
111
+ const value = Math.max(0, Number(valueRaw));
100
112
  const experience = Math.max(0, Math.min(100, Number(d.experience || 0)));
101
113
  const size = Math.max(2, Math.round((value / maxValue) * 100)); // 2..100
102
114
  const color = 'var(--content-card-background)';
@@ -104,11 +116,13 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
104
116
  label: d.axis,
105
117
  value: size,
106
118
  color,
107
- tooltip: `${d.axis}\nRatio: ${value}\nExperience: ${experience}`,
108
- data: { ratio: value, experience }
119
+ tooltip: evidenceAvailable
120
+ ? `${d.axis}\nEvidence: ${value} sources\nExperience: ${experience}`
121
+ : `${d.axis}\nRatio: ${ratio}\nExperience: ${experience}`,
122
+ data: { ratio, experience, evidence: value }
109
123
  };
110
124
  });
111
- }, [skillsRadarLimited]);
125
+ }, [skillsRadarLimited, skillsCategoryRadar]);
112
126
 
113
127
  const bubbleData = useMemo(() => {
114
128
  return bubbles.map((b) => ({
@@ -144,15 +158,16 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
144
158
  const enriched = list.map((name) => {
145
159
  const meta = skillsMeta?.[name] || {};
146
160
  const sources = Array.isArray((meta as any).sources) ? (meta as any).sources as string[] : [];
147
- return { name, years: Number(meta.years || 0), presence: (meta.presence as string) || '', sources };
161
+ const presenceTypes = Array.isArray((meta as any).presenceTypes) ? ((meta as any).presenceTypes as Array<'certified' | 'observed' | 'self-reported'>) : ((meta as any).presence ? [String((meta as any).presence) as any] : []);
162
+ return { name, years: Number(meta.years || 0), presence: (meta as any).presence as string | undefined, presenceTypes, sources };
148
163
  }).sort((a, b) => b.years - a.years || a.name.localeCompare(b.name));
149
164
  const items = enriched.slice(0, 10);
150
165
  const overflow = list.length - items.length;
151
- const display: Array<{ label: string; years?: number; presence?: string; sources?: string[] } | ''> = [];
166
+ const display: Array<{ label: string; years?: number; presence?: string; presenceTypes?: Array<'certified' | 'observed' | 'self-reported'>; sources?: string[] } | ''> = [];
152
167
  for (let i = 0; i < Math.min(9, items.length); i++) {
153
168
  const it = items[i];
154
169
  const label = it.name ? `${it.name}` : '';
155
- display.push({ label, years: it.years, presence: it.presence, sources: it.sources });
170
+ display.push({ label, years: it.years, presence: it.presence, presenceTypes: it.presenceTypes, sources: it.sources });
156
171
  }
157
172
  if (list.length > 10) {
158
173
  // Aggregate years and sources for remaining datapoints beyond the top 10
@@ -168,11 +183,11 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
168
183
  display.push({ label: `Others (${overflow})`, years: aggregatedYears, sources: aggregatedSources });
169
184
  } else if (items.length >= 10) {
170
185
  const it = items[9];
171
- display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, sources: it?.sources });
186
+ display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, presenceTypes: it?.presenceTypes, sources: it?.sources });
172
187
  } else {
173
188
  if (items.length > 9) {
174
189
  const it = items[9];
175
- display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, sources: it?.sources });
190
+ display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, presenceTypes: it?.presenceTypes, sources: it?.sources });
176
191
  }
177
192
  }
178
193
  while (display.length < 10) display.push('');
@@ -193,17 +208,41 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
193
208
  const presenceColor = (presence?: string) => {
194
209
  const p = String(presence || '').toLowerCase();
195
210
  if (p === 'self-reported') return green5;
196
- if (p === 'observed') return green3;
211
+ if (p === 'observed') return green2;
197
212
  if (p === 'certified') return green1;
198
213
  return 'var(--icon-button-secondary)';
199
214
  };
200
215
 
201
- const presenceTooltipCopy = (presence?: string): { title: string; body: string } => {
202
- const p = String(presence || '').toLowerCase();
203
- if (p === 'self-reported') return { title: 'Self-reported', body: 'Claims (bios, profiles, resumes).' };
204
- if (p === 'observed') return { title: 'Observed', body: 'Evidence directly from code and repos.' };
205
- if (p === 'certified') return { title: 'Certified', body: 'Verified by credential issuers.' };
206
- return { title: 'Info', body: '' };
216
+ const presenceLegendTooltip = (): { title: string; body: React.ReactNode } => {
217
+ const Row = ({ color, label }: { color: string; label: string }) => (
218
+ <div className="flex items-center gap-2">
219
+ <span className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />
220
+ <span>{label}</span>
221
+ </div>
222
+ );
223
+ return {
224
+ title: 'Presence types',
225
+ body: (
226
+ <div className="grid gap-1">
227
+ <Row color={presenceColor('certified')} label="Certified — Verified by credential issuers." />
228
+ <Row color={presenceColor('observed')} label="Observed — Evidence directly from code and repos." />
229
+ <Row color={presenceColor('self-reported')} label="Self-reported — Claims (bios, profiles, resumes)." />
230
+ </div>
231
+ )
232
+ };
233
+ };
234
+
235
+ const experienceLegendTooltip = (): { title: string; body: React.ReactNode } => {
236
+ return {
237
+ title: 'Experience',
238
+ body: (
239
+ <div className="grid gap-1 mt-2">
240
+ <div>
241
+ Calculated as time between first and most recent observed evidence across repositories.
242
+ </div>
243
+ </div>
244
+ )
245
+ };
207
246
  };
208
247
 
209
248
  const leftColumnGrid = useMemo(() => (skillsGrid || []).slice(0, 5), [skillsGrid]);
@@ -211,7 +250,7 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
211
250
 
212
251
  if (!hasRadar) return null;
213
252
 
214
- const columnComponent = (entry: { label: string; years?: number; presence?: string; sources?: string[] } | '', idx: number, isLeft: boolean) => {
253
+ const columnComponent = (entry: { label: string; years?: number; presence?: string; presenceTypes?: Array<'certified' | 'observed' | 'self-reported'>; sources?: string[] } | '', idx: number, isLeft: boolean) => {
215
254
  return (
216
255
  <div key={idx} className="flex items-stretch justify-between gap-3 min-w-0">
217
256
  <div className="flex flex-col min-w-0 justify-center">
@@ -219,59 +258,115 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
219
258
  <span className={'inline-block h-2 w-2 rounded-full'} style={{ backgroundColor: entry ? 'var(--icon-button-secondary)' : 'transparent', flexShrink: 0 }} />
220
259
  <span className="shrink-0 opacity-70 ">{idx + (isLeft ? 1 : 6)}.</span>
221
260
  {entry && typeof entry !== 'string' ? (
222
- <span className="truncate" title={entry.label}>{entry.label}</span>
261
+ <span className="truncate" title={entry.label}>
262
+ {entry.label}
263
+ {/* Evidence count bullet */}
264
+ {(() => {
265
+ const meta = skillsMeta?.[entry.label];
266
+ const count = Number((meta as any)?.evidenceCount || 0);
267
+ return count > 0 ? (
268
+ <>
269
+ {' '}<span className="opacity-60">•</span>{' '}
270
+ <span
271
+ className="underline decoration-dotted underline-offset-2 cursor-help opacity-80"
272
+ onMouseEnter={(e) =>
273
+ showLegendTooltipAt(
274
+ e.currentTarget,
275
+ 'Evidence count',
276
+ 'Total number of sources across observed, certified, and self-reported evidence for this skill.'
277
+ )
278
+ }
279
+ onMouseLeave={hideLegendTooltip}
280
+ >
281
+ {count}
282
+ </span>
283
+ </>
284
+ ) : null;
285
+ })()}
286
+ </span>
223
287
  ) : (
224
288
  <span className="truncate">{typeof entry === 'string' ? entry : '\u00A0'}</span>
225
289
  )}
226
290
  </div>
227
291
  <span className="text-xs text-[var(--text-secondary)] flex flex-wrap items-center gap-1">
228
- <span
229
- className="underline decoration-dotted underline-offset-2 cursor-help"
230
- onMouseEnter={(e) => showLegendTooltipAt(e.currentTarget, 'Sources', 'The source where we observed this skill.')}
231
- onMouseLeave={hideLegendTooltip}
232
- >
233
- Sources
234
- </span>:
235
- {Array.isArray((entry as any).sources) && (entry as any).sources.length > 0 ? (
236
- (() => {
237
- const sourceProviders: string[] = ((entry as any).sources as string[]).map((src: string) => {
238
- const str = String(src);
239
- let provider = str.split(':')[0] || '';
240
- if (!provider || provider === str) {
241
- // If split(':')[0] didn't find a delimiter or provider (i.e., no ':'), try split('.')
242
- provider = str.split('.')[0] || '';
243
- }
244
- return provider.toLowerCase();
245
- });
246
- const uniqueProviders = Array.from(new Set<string>(sourceProviders));
247
- const filteredProviders = uniqueProviders.filter((provider) =>
248
- providers.includes(provider.toLowerCase())
249
- );
250
- return filteredProviders.map((provider) => (
251
- <ProviderIcon key={provider} name={provider} />
252
- ));
253
- })()
254
- ) : null}
292
+ {entry && typeof entry !== 'string' ? (
293
+ <>
294
+ <span
295
+ className="underline decoration-dotted underline-offset-2 cursor-help"
296
+ onMouseEnter={(e) => showLegendTooltipAt(e.currentTarget, 'Sources', 'The source where we observed this skill.')}
297
+ onMouseLeave={hideLegendTooltip}
298
+ >
299
+ Sources
300
+ </span>:
301
+ {Array.isArray((entry as any).sources) && (entry as any).sources.length > 0 ? (
302
+ (() => {
303
+ const sourceProviders: string[] = ((entry as any).sources as string[]).map((src: string) => {
304
+ const str = String(src);
305
+ let provider = str.split(':')[0] || '';
306
+ if (!provider || provider === str) {
307
+ // If split(':')[0] didn't find a delimiter or provider (i.e., no ':'), try split('.')
308
+ provider = str.split('.')[0] || '';
309
+ }
310
+ return provider.toLowerCase();
311
+ });
312
+ const uniqueProviders = Array.from(new Set<string>(sourceProviders));
313
+ const filteredProviders = uniqueProviders.filter((provider) =>
314
+ providers.includes(provider.toLowerCase())
315
+ );
316
+ return filteredProviders.map((provider) => (
317
+ <ProviderIcon key={provider} name={provider} />
318
+ ));
319
+ })()
320
+ ) : null}
321
+ </>
322
+ ) : (
323
+ <span className="opacity-0 whitespace-nowrap">'\u00A0'</span>
324
+ )}
255
325
  </span>
256
326
  </div>
257
327
  {entry && typeof entry !== 'string' ? (
258
- <div className="flex flex-col items-end leading-tight h-full justify-between text-base">
259
- {entry.years ? <span className="whitespace-nowrap text-[var(--text-secondary)]">{`${entry.years} Years`}</span> : <span className="opacity-0 whitespace-nowrap text-[var(--text-secondary)]">0 Years</span>}
260
- {entry.presence ? (
261
- <div className="flex items-center gap-1">
262
- <span className="inline-block h-2 w-2 rounded-full text-sm" style={{ background: presenceColor(entry.presence) }} />
328
+ <div
329
+ className="flex flex-col items-end leading-tight h-full justify-start text-base"
330
+ >
331
+ <div className="pb-1">
332
+ {entry.years ? (
263
333
  <span
264
- className="whitespace-nowrap text-sm underline decoration-dotted underline-offset-2 cursor-help"
334
+ className="whitespace-nowrap text-[var(--text-secondary)] underline decoration-dotted underline-offset-2 cursor-help"
265
335
  onMouseEnter={(e) => {
266
- const copy = presenceTooltipCopy(entry.presence);
336
+ const copy = experienceLegendTooltip();
267
337
  showLegendTooltipAt(e.currentTarget, copy.title, copy.body);
268
338
  }}
269
339
  onMouseLeave={hideLegendTooltip}
270
340
  >
271
- {entry.presence}
341
+ {`${entry.years} Years`}
272
342
  </span>
273
- </div>
274
- ) : <span className="opacity-0 whitespace-nowrap">.</span>}
343
+ ) : (
344
+ <span className="opacity-0 whitespace-nowrap text-[var(--text-secondary)]">0 Years</span>
345
+ )}
346
+ </div>
347
+ <div
348
+ onMouseEnter={(e) => {
349
+ const copy = presenceLegendTooltip();
350
+ showLegendTooltipAt(e.currentTarget, copy.title, copy.body);
351
+ }}
352
+ onMouseLeave={hideLegendTooltip}
353
+ className="pt-1"
354
+ >
355
+ {(() => {
356
+ const types = Array.isArray(entry.presenceTypes) ? entry.presenceTypes : (entry.presence ? [String(entry.presence) as any] : []);
357
+ const hasAny = types.length > 0;
358
+ return hasAny ? (
359
+ <div
360
+ className="flex items-center gap-1"
361
+
362
+ >
363
+ {types.map((t) => (
364
+ <span key={t} className="inline-block h-2 w-2 rounded-full" style={{ background: presenceColor(t) }} />
365
+ ))}
366
+ </div>
367
+ ) : <span className="opacity-0 whitespace-nowrap">.</span>;
368
+ })()}
369
+ </div>
275
370
  </div>
276
371
  ) : null}
277
372
  </div>
@@ -297,7 +392,7 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
297
392
  >
298
393
  <div className="flex items-center gap-2">
299
394
  <span className="inline-block h-3 w-3 rounded-full" style={{ background: green1 }} />
300
- <span>Size = ratio of observed/self-reported/certified</span>
395
+ <span>Size = evidence count per category</span>
301
396
  </div>
302
397
  <div className="flex items-center gap-2 mt-1">
303
398
  <span className="inline-block h-3 w-3 rounded-full" style={{ background: green5 }} />
@@ -242,10 +242,12 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
242
242
  </div>
243
243
  <h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>How your data is handled</h3>
244
244
  <p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md mt-2 leading-relaxed">
245
- Hey there! We understand that giving access to your private repositories can be a bit scary. So here's the deal: We install the KYD GitHub App in your account - we only have read access. Then, once you request a badge assessment, we read the repositories and analyze the code, then its deleted, forever. Your code is not accessible to anyone, not even us.
245
+ We understand that giving access to your private repositories can be a bit scary. So here's the deal: We install the KYD GitHub App in your account. The app has
246
+ <span className="mx-1"><Link href="https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28#repository-permissions-for-contents" target="_blank" rel="noopener noreferrer" className="underline" style={{ color: 'var(--icon-accent)'}}>Contents <ExternalLink className="size-3 inline-block" /></Link></span>
247
+ read access - only to the repositories you select. Then, once you request a badge assessment, we read the repositories and analyze the code, then its deleted, forever. Your code is not accessible to anyone, not even us.
246
248
  </p>
247
249
  <p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md mt-3 leading-relaxed">
248
- For details, see our{' '}
250
+ For other details, see our{' '}
249
251
  <Link href="https://www.knowyourdeveloper.ai/privacy-policy" target="_blank" rel="noopener noreferrer" className="underline" style={{ color: 'var(--icon-accent)'}}>
250
252
  Privacy Policy <ExternalLink className="size-3 inline-block ml-1" />
251
253
  </Link>.
@@ -368,7 +370,7 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
368
370
  onClick={() => setShowDataHandling(true)}
369
371
  className="sm:text-sm text-xs underline text-[var(--icon-accent)] hover:text-[var(--icon-accent-hover)]"
370
372
  >
371
- See how your data is handled
373
+ How KYD Handles Your Data
372
374
  </button>
373
375
  </p>
374
376
  <div className="flex flex-col sm:flex-row w-full gap-3 mt-2 justify-center items-center">
package/src/types.ts CHANGED
@@ -397,11 +397,21 @@ export interface GraphInsightsPayload {
397
397
  certified?: number; // 0-100
398
398
  // New: experience metric (0-100) for color saturation
399
399
  experience?: number;
400
+ // New: total evidence count across all skills in this category
401
+ evidence_count_total?: number;
400
402
  }>;
401
403
  // New: mapping of category -> list of skills contributing to that category
402
404
  skillsByCategory?: Record<string, string[]>;
403
405
  // New: per-skill metadata used by UI (e.g., presence label, experience years)
404
- skillsMeta?: Record<string, { presence?: 'certified' | 'observed' | 'self-reported'; years?: number; sources?: string[] }>;
406
+ skillsMeta?: Record<string, {
407
+ presence?: 'certified' | 'observed' | 'self-reported';
408
+ // New: list of presence types for multi-dot rendering
409
+ presenceTypes?: Array<'certified' | 'observed' | 'self-reported'>;
410
+ years?: number;
411
+ sources?: string[];
412
+ // New: total number of evidence sources across observed/self-reported/certified
413
+ evidenceCount?: number;
414
+ }>;
405
415
  // New: Flattened list of business rule selections (for appendix)
406
416
  business_rules_all?: Array<{
407
417
  provider: string;