kyd-shared-badge 0.3.98 → 0.3.100

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.98",
3
+ "version": "0.3.100",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -27,6 +27,7 @@ import UseCases from './components/UseCases';
27
27
  import SummaryCards from './components/SummaryCards';
28
28
  import GaugeCard from './components/GaugeCard';
29
29
  import RiskCard from './components/RiskCard';
30
+ import RoleOverviewCard from './components/RoleOverviewCard';
30
31
  import TopContributingFactors from './components/TopContributingFactors';
31
32
  import AiUsageBody from './components/AiUsageBody';
32
33
  import SanctionsMatches from './components/SanctionsMatches';
@@ -210,7 +211,7 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
210
211
  {/* Quadrant layout */}
211
212
  <div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
212
213
  {/* Top-left: Name, countries, badge image handled by ReportHeader */}
213
- <div>
214
+ <div className={`${hasEnterpriseMatch ? '' : 'md:col-span-2'}`}>
214
215
  <ReportHeader
215
216
  badgeId={badgeId}
216
217
  developerName={badgeData.developerName}
@@ -226,25 +227,22 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
226
227
  />
227
228
  </div>
228
229
  {/* 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>
240
- </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>
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
+ )}
248
246
 
249
247
  {/* Bottom-left: Technical score */}
250
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,13 +86,22 @@ 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}
97
+ labels={{
98
+ valueLabel: {
99
+ // Hide center text; show tier labels around the arc instead
100
+ formatTextValue: () => '',
101
+ matchColorWithArc: true,
102
+ },
103
+ tickLabels: tickLabels,
104
+ }}
73
105
  arc={{
74
106
  padding: 0.02,
75
107
  // Explicit subArcs ensure left->right map: red -> yellow -> green
@@ -91,7 +123,6 @@ export default function GaugeCard({
91
123
  </div>
92
124
  )}
93
125
  </div>
94
- <div className={'mt-3 text-sm font-semibold'} style={{ color: 'var(--text-main)' }}>{displayLabel}</div>
95
126
  </div>
96
127
  {Array.isArray(topMovers) && topMovers.length > 0 && (
97
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 }}>
@@ -0,0 +1,114 @@
1
+ 'use client';
2
+
3
+ import React, { useMemo } from 'react';
4
+ import Link from 'next/link';
5
+ import { FiInfo } from 'react-icons/fi';
6
+ import GaugeComponent from '@knowyourdeveloper/react-gauge-component';
7
+ import { clampPercent, hexToRgba, scoreToColorHex, red, yellow, green } from '../colors';
8
+
9
+ export default function RoleOverviewCard({
10
+ title,
11
+ matchLabel,
12
+ roleName,
13
+ }: {
14
+ title: string;
15
+ matchLabel?: string;
16
+ roleName?: string;
17
+ }) {
18
+ const pct = useMemo(() => {
19
+ const raw = String(matchLabel || '').toLowerCase();
20
+ let mapped = 0;
21
+ if (raw.includes('optimal')) mapped = 100;
22
+ else if (raw.includes('strong')) mapped = 75;
23
+ else if (raw.includes('partial')) mapped = 50;
24
+ else if (raw.includes('weak')) mapped = 25;
25
+ else mapped = 0; // incompatible or unknown
26
+ return clampPercent(mapped);
27
+ }, [matchLabel]);
28
+
29
+ const headerTint = hexToRgba(scoreToColorHex(pct), 0.06);
30
+ const displayLabel = String(matchLabel || '').trim();
31
+
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
+ }), []);
55
+
56
+ return (
57
+ <div
58
+ className={'rounded-md p-5 border flex flex-col h-full'}
59
+ style={{
60
+ backgroundColor: 'var(--content-card-background)',
61
+ borderColor: 'var(--icon-button-secondary)',
62
+ backgroundImage: `linear-gradient(${headerTint}, ${headerTint})`,
63
+ }}
64
+ >
65
+ <div className="mb-3 flex items-start justify-between gap-2">
66
+ <div>
67
+ <div className={'font-semibold'} style={{ color: 'var(--text-main)' }}>{title}</div>
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>
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>
79
+ </div>
80
+ <div className="flex-grow flex flex-col items-center justify-center" style={{ minHeight: 200 }}>
81
+ <div className="relative" style={{ width: '100%', aspectRatio: '2 / 1', maxWidth: 360 }}>
82
+ <GaugeComponent
83
+ type="semicircle"
84
+ style={{ width: '100%', height: '100%' }}
85
+ value={pct}
86
+ minValue={0}
87
+ maxValue={100}
88
+ labels={{
89
+ valueLabel: {
90
+ formatTextValue: () => displayLabel,
91
+ matchColorWithArc: true,
92
+ },
93
+ tickLabels: tickLabels,
94
+ }}
95
+ arc={{
96
+ padding: 0.02,
97
+ gradient: true,
98
+ colorArray: [red, yellow, green],
99
+ }}
100
+ pointer={{ type: 'arrow', elastic: true, animationDelay: 0 }}
101
+ />
102
+ </div>
103
+ {roleName ? (
104
+ <div className="mt-3 text-center">
105
+ <Link href="#role" className={'text-sm font-semibold text-[var(--text-main)] hover:underline'}>{roleName}</Link>
106
+ </div>
107
+ ) : null}
108
+ </div>
109
+
110
+ </div>
111
+ );
112
+ }
113
+
114
+
@@ -20,7 +20,7 @@ type HoverTooltipState = {
20
20
  x: number;
21
21
  y: number;
22
22
  title: string;
23
- body?: string;
23
+ body?: React.ReactNode;
24
24
  } | null;
25
25
 
26
26
  const TooltipBox = ({ state }: { state: HoverTooltipState }) => {
@@ -50,7 +50,7 @@ const TooltipBox = ({ state }: { state: HoverTooltipState }) => {
50
50
  };
51
51
 
52
52
 
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 }) {
53
+ 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[] }>; headless?: boolean }) {
54
54
  const hasRadar = !!(skillsCategoryRadar && skillsCategoryRadar.length > 0);
55
55
  const skillsRadarLimited = (skillsCategoryRadar || []).slice(0, 24);
56
56
  const containerRef = useRef<HTMLDivElement>(null);
@@ -58,6 +58,7 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
58
58
  const [legendTooltip, setLegendTooltip] = useState<HoverTooltipState>(null);
59
59
  const [activeCategory, setActiveCategory] = useState<string | null>(null);
60
60
  const [legendHovered, setLegendHovered] = useState<boolean>(false);
61
+
61
62
  useEffect(() => {
62
63
  if (typeof window !== 'undefined') {
63
64
  const id = window.setTimeout(() => {
@@ -77,7 +78,7 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
77
78
  const y = rect.bottom - hostRect.top + 6;
78
79
  return { x, y };
79
80
  };
80
- const showLegendTooltipAt = (target: EventTarget | null, title: string, body?: string) => {
81
+ const showLegendTooltipAt = (target: EventTarget | null, title: string, body?: React.ReactNode) => {
81
82
  if (!(target instanceof HTMLElement)) return;
82
83
  const { x, y } = computeTooltipPosition(target);
83
84
  setLegendTooltip({ visible: true, x, y, title, body });
@@ -144,15 +145,16 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
144
145
  const enriched = list.map((name) => {
145
146
  const meta = skillsMeta?.[name] || {};
146
147
  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 };
148
+ 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] : []);
149
+ return { name, years: Number(meta.years || 0), presence: (meta as any).presence as string | undefined, presenceTypes, sources };
148
150
  }).sort((a, b) => b.years - a.years || a.name.localeCompare(b.name));
149
151
  const items = enriched.slice(0, 10);
150
152
  const overflow = list.length - items.length;
151
- const display: Array<{ label: string; years?: number; presence?: string; sources?: string[] } | ''> = [];
153
+ const display: Array<{ label: string; years?: number; presence?: string; presenceTypes?: Array<'certified' | 'observed' | 'self-reported'>; sources?: string[] } | ''> = [];
152
154
  for (let i = 0; i < Math.min(9, items.length); i++) {
153
155
  const it = items[i];
154
156
  const label = it.name ? `${it.name}` : '';
155
- display.push({ label, years: it.years, presence: it.presence, sources: it.sources });
157
+ display.push({ label, years: it.years, presence: it.presence, presenceTypes: it.presenceTypes, sources: it.sources });
156
158
  }
157
159
  if (list.length > 10) {
158
160
  // Aggregate years and sources for remaining datapoints beyond the top 10
@@ -168,11 +170,11 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
168
170
  display.push({ label: `Others (${overflow})`, years: aggregatedYears, sources: aggregatedSources });
169
171
  } else if (items.length >= 10) {
170
172
  const it = items[9];
171
- display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, sources: it?.sources });
173
+ display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, presenceTypes: it?.presenceTypes, sources: it?.sources });
172
174
  } else {
173
175
  if (items.length > 9) {
174
176
  const it = items[9];
175
- display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, sources: it?.sources });
177
+ display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, presenceTypes: it?.presenceTypes, sources: it?.sources });
176
178
  }
177
179
  }
178
180
  while (display.length < 10) display.push('');
@@ -193,17 +195,41 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
193
195
  const presenceColor = (presence?: string) => {
194
196
  const p = String(presence || '').toLowerCase();
195
197
  if (p === 'self-reported') return green5;
196
- if (p === 'observed') return green3;
198
+ if (p === 'observed') return green2;
197
199
  if (p === 'certified') return green1;
198
200
  return 'var(--icon-button-secondary)';
199
201
  };
200
202
 
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: '' };
203
+ const presenceLegendTooltip = (): { title: string; body: React.ReactNode } => {
204
+ const Row = ({ color, label }: { color: string; label: string }) => (
205
+ <div className="flex items-center gap-2">
206
+ <span className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />
207
+ <span>{label}</span>
208
+ </div>
209
+ );
210
+ return {
211
+ title: 'Presence types',
212
+ body: (
213
+ <div className="grid gap-1">
214
+ <Row color={presenceColor('certified')} label="Certified — Verified by credential issuers." />
215
+ <Row color={presenceColor('observed')} label="Observed — Evidence directly from code and repos." />
216
+ <Row color={presenceColor('self-reported')} label="Self-reported — Claims (bios, profiles, resumes)." />
217
+ </div>
218
+ )
219
+ };
220
+ };
221
+
222
+ const experienceLegendTooltip = (): { title: string; body: React.ReactNode } => {
223
+ return {
224
+ title: 'Experience',
225
+ body: (
226
+ <div className="grid gap-1 mt-2">
227
+ <div>
228
+ Calculated as time between first and most recent observed evidence across repositories.
229
+ </div>
230
+ </div>
231
+ )
232
+ };
207
233
  };
208
234
 
209
235
  const leftColumnGrid = useMemo(() => (skillsGrid || []).slice(0, 5), [skillsGrid]);
@@ -211,7 +237,7 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
211
237
 
212
238
  if (!hasRadar) return null;
213
239
 
214
- const columnComponent = (entry: { label: string; years?: number; presence?: string; sources?: string[] } | '', idx: number, isLeft: boolean) => {
240
+ const columnComponent = (entry: { label: string; years?: number; presence?: string; presenceTypes?: Array<'certified' | 'observed' | 'self-reported'>; sources?: string[] } | '', idx: number, isLeft: boolean) => {
215
241
  return (
216
242
  <div key={idx} className="flex items-stretch justify-between gap-3 min-w-0">
217
243
  <div className="flex flex-col min-w-0 justify-center">
@@ -255,23 +281,48 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
255
281
  </span>
256
282
  </div>
257
283
  {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) }} />
284
+ <div
285
+ className="flex flex-col items-end leading-tight h-full justify-start text-base"
286
+ >
287
+ <div className="pb-1">
288
+ {entry.years ? (
263
289
  <span
264
- className="whitespace-nowrap text-sm underline decoration-dotted underline-offset-2 cursor-help"
290
+ className="whitespace-nowrap text-[var(--text-secondary)] underline decoration-dotted underline-offset-2 cursor-help"
265
291
  onMouseEnter={(e) => {
266
- const copy = presenceTooltipCopy(entry.presence);
292
+ const copy = experienceLegendTooltip();
267
293
  showLegendTooltipAt(e.currentTarget, copy.title, copy.body);
268
294
  }}
269
295
  onMouseLeave={hideLegendTooltip}
270
296
  >
271
- {entry.presence}
297
+ {`${entry.years} Years`}
272
298
  </span>
273
- </div>
274
- ) : <span className="opacity-0 whitespace-nowrap">.</span>}
299
+ ) : (
300
+ <span className="opacity-0 whitespace-nowrap text-[var(--text-secondary)]">0 Years</span>
301
+ )}
302
+ </div>
303
+ <div
304
+ onMouseEnter={(e) => {
305
+ const copy = presenceLegendTooltip();
306
+ showLegendTooltipAt(e.currentTarget, copy.title, copy.body);
307
+ }}
308
+ onMouseLeave={hideLegendTooltip}
309
+ className="pt-1"
310
+ >
311
+ {(() => {
312
+ const types = Array.isArray(entry.presenceTypes) ? entry.presenceTypes : (entry.presence ? [String(entry.presence) as any] : []);
313
+ const hasAny = types.length > 0;
314
+ return hasAny ? (
315
+ <div
316
+ className="flex items-center gap-1"
317
+
318
+ >
319
+ {types.map((t) => (
320
+ <span key={t} className="inline-block h-2 w-2 rounded-full" style={{ background: presenceColor(t) }} />
321
+ ))}
322
+ </div>
323
+ ) : <span className="opacity-0 whitespace-nowrap">.</span>;
324
+ })()}
325
+ </div>
275
326
  </div>
276
327
  ) : null}
277
328
  </div>
@@ -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">