kyd-shared-badge 0.3.86 → 0.3.88

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.86",
3
+ "version": "0.3.88",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -21,6 +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.0",
24
25
  "@radix-ui/react-slot": "^1.2.3",
25
26
  "@radix-ui/react-tooltip": "^1.2.8",
26
27
  "ai": "5.0.47",
@@ -17,7 +17,8 @@ import GaugeCard from './components/GaugeCard';
17
17
  import RiskCard from './components/RiskCard';
18
18
 
19
19
  import { yellow, green } from './colors';
20
- import Skills from './components/Skills';
20
+ import SkillsValidation from './components/SkillsValidation';
21
+ import SkillsBubble from './components/SkillsBubble';
21
22
  import CategoryBars from './components/CategoryBars';
22
23
  import SkillsAppendixTable from './components/SkillsAppendixTable';
23
24
  import { BusinessRulesProvider } from './components/BusinessRulesContext';
@@ -212,7 +213,7 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
212
213
  <div className="pt-8 border-t kyd-avoid-break" style={{ borderColor: 'var(--icon-button-secondary)'}}>
213
214
  <h3 className={'text-xl font-bold mb-3 kyd-keep-with-next'} style={{ color: 'var(--text-main)' }}>KYD Technical - Skills Insights</h3>
214
215
  <div className={'prose prose-sm max-w-none mb-6 space-y-4'} style={{ color: 'var(--text-secondary)' }}>
215
- <Skills skillsMatrix={skillsMatrix} skillsCategoryRadar={graphInsights?.skillsCategoryRadar} headless={isHeadless} />
216
+ <SkillsValidation skillsCategoryRadar={graphInsights?.skillsCategoryRadar} headless={isHeadless} />
216
217
  </div>
217
218
  </div>
218
219
  </Reveal>
@@ -15,7 +15,8 @@ import CodeIcon from './components/icons/code';
15
15
  import RiskIcon from './components/icons/risk';
16
16
 
17
17
  import { yellow } from './colors';
18
- import Skills from './components/Skills';
18
+ import SkillsBubble from './components/SkillsBubble';
19
+ import SkillsValidation from './components/SkillsValidation';
19
20
  import CategoryBars from './components/CategoryBars';
20
21
  import SkillsAppendixTable from './components/SkillsAppendixTable';
21
22
  import { BusinessRulesProvider } from './components/BusinessRulesContext';
@@ -24,6 +25,8 @@ import { formatLocalDateTime } from './utils/date';
24
25
  import ChatWidget from './chat/ChatWidget';
25
26
  import UseCases from './components/UseCases';
26
27
  import SummaryCards from './components/SummaryCards';
28
+ import GaugeCard from './components/GaugeCard';
29
+ import RiskCard from './components/RiskCard';
27
30
  import TopContributingFactors from './components/TopContributingFactors';
28
31
  import AiUsageBody from './components/AiUsageBody';
29
32
  import SanctionsMatches from './components/SanctionsMatches';
@@ -204,28 +207,93 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
204
207
  const OverviewSection = () => (
205
208
  <div className={`${wrapperMaxWidth} mx-auto mt-6`}>
206
209
  <Reveal headless={isHeadless} offsetY={8} durationMs={500}>
207
- <ReportHeader
208
- badgeId={badgeId}
209
- developerName={badgeData.developerName}
210
- updatedAt={updatedAt}
211
- score={overallFinalPercent || 0}
212
- isPublic={true}
213
- badgeImageUrl={badgeData.badgeImageUrl || ''}
214
- summary={assessmentResult?.report_summary || ''}
215
- enterpriseMatch={(() => {
216
- const em = assessmentResult?.enterprise_match;
217
- if (!em) return null;
218
- const role = em.role || {};
219
- return { label: em.label, description: em.description, roleName: role.name };
220
- })()}
221
- countries={(assessmentResult?.screening_sources?.ip_risk_analysis?.raw_data?.countries) || []}
222
- accountAuthenticity={assessmentResult?.account_authenticity}
223
- />
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}
245
+ </div>
246
+ );
247
+ })()}
248
+ </div>
249
+
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>
284
+ </div>
285
+ </div>
224
286
  </Reveal>
287
+
288
+ {/* Full-width Skills bubble chart below quadrant */}
225
289
  <div className={'rounded-xl shadow-xl p-6 sm:p-8 mt-6 border'} style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}>
226
- <Reveal headless={isHeadless} as={'h4'} offsetY={8} durationMs={500} className={'text-2xl font-semibold mb-4'} style={{ color: 'var(--text-main)' }}>Report Summary</Reveal>
227
- <SummaryCards graphInsights={graphInsights} assessmentResult={assessmentResult} topBusinessForGenre={topBusinessForGenre} />
228
- <div className={'pt-8 text-sm text-center'} style={{ color: 'var(--text-secondary)' }}>
290
+ <Reveal headless={isHeadless}>
291
+ <div className={'kyd-avoid-break'}>
292
+ <h4 className={'text-2xl font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Skills Footprint</h4>
293
+ <SkillsBubble skillsCategoryRadar={graphInsights?.skillsCategoryRadar} headless={isHeadless} />
294
+ </div>
295
+ </Reveal>
296
+ <div className={'pt-6 text-sm text-center'} style={{ color: 'var(--text-secondary)' }}>
229
297
  Report Completed: {formatLocalDateTime(updatedAt)}
230
298
  </div>
231
299
  </div>
@@ -392,7 +460,7 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
392
460
  <div className="pt-8 border-t kyd-avoid-break" style={{ borderColor: 'var(--icon-button-secondary)'}}>
393
461
  <h3 className={'text-xl font-bold mb-3 kyd-keep-with-next'} style={{ color: 'var(--text-main)' }}>KYD Technical - Skills Insights</h3>
394
462
  <div className={'prose prose-sm max-w-none mb-6 space-y-4'} style={{ color: 'var(--text-secondary)' }}>
395
- <Skills skillsMatrix={skillsMatrix} skillsCategoryRadar={graphInsights?.skillsCategoryRadar} headless={isHeadless} />
463
+ <SkillsValidation skillsCategoryRadar={graphInsights?.skillsCategoryRadar} headless={isHeadless} />
396
464
  </div>
397
465
  </div>
398
466
  </Reveal>
@@ -49,12 +49,6 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
49
49
  const tint = hexToRgba(pickTint(score || 0), 0.06);
50
50
  const matchLabel = enterpriseMatch?.label;
51
51
 
52
- const formattedDate = updatedAt ? formatLocalDate(updatedAt, {
53
- year: 'numeric',
54
- month: 'long',
55
- day: 'numeric',
56
- }) : 'N/A';
57
-
58
52
  return (
59
53
  <div
60
54
  className={'mb-8 p-6 rounded-xl shadow-lg border'}
@@ -79,33 +73,15 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
79
73
  </div>
80
74
  );
81
75
  })()}
82
- <div className="flex flex-col md:flex-row items-center md:items-stretch gap-6">
83
- {/* Left Half: Badge Image with robust centered overlay */}
84
- <div className="w-full md:w-1/3 flex items-center justify-center self-stretch">
85
- <div className="relative w-full max-w-xs select-none">
86
- <Image src={finalBadgeImageUrl} alt="KYD Badge" width={400} height={400} priority className='w-full h-auto pointer-events-none p-10'/>
87
- {/* Centered overlay slightly lower on Y axis, responsive and readable */}
88
- <div className="pointer-events-none absolute left-1/2 top-[66%] -translate-x-1/2 -translate-y-1/2">
89
- <div className="font-extrabold text-black text-3xl " >
90
- {Math.round(score || 0)}%
91
- </div>
92
- </div>
93
- </div>
94
- </div>
95
-
96
- {/* Right Half: Title, Candidate, Details and Summary section */}
97
- <div className="w-full md:w-2/3">
98
- <div className={'space-y-4'}>
76
+ <div className="flex flex-col gap-6">
77
+ {/* Info section: Title, Candidate, Details and Summary */}
78
+ <div className="w-full">
79
+ <div className='space-y-2'>
99
80
  <span className='flex gap-2 w-full items-end text-start justify-start'>
100
81
  <h2 className={'text-xl font-light'} style={{ color: 'var(--text-main)' }}>KYD Candidate Report:</h2>
101
82
  <div className={'text-xl font-bold'} style={{ color: 'var(--text-main)' }}>{developerName || 'N/A'}</div>
102
83
  </span>
103
- <div className={'text-sm space-y-2'}>
104
- <p><span className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>Developer:</span> <span style={{ color: 'var(--text-main)' }}>{developerName || 'N/A'}</span></p>
105
- <p><span className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>Requested By:</span> <span style={{ color: 'var(--text-main)' }}>{developerName || 'N/A'}</span></p>
106
- <p><span className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>Organization:</span> <span style={{ color: 'var(--text-main)' }}>{companyName || 'Unaffiliated'}</span></p>
107
- <p><span className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>Date Generated:</span> <span style={{ color: 'var(--text-main)' }}>{formattedDate}</span></p>
108
- <p><span className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>Report ID:</span> <span style={{ color: 'var(--text-main)' }}>{badgeId || 'N/A'}</span></p>
84
+ <div className={'text-sm'}>
109
85
  {Array.isArray(countries) && countries.length > 0 && (
110
86
  (() => {
111
87
  const countryNames = countries
@@ -155,6 +131,18 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
155
131
  )}
156
132
  </div>
157
133
  </div>
134
+
135
+ {/* Badge Image with robust centered overlay */}
136
+ <div className="w-full flex items-center justify-center self-stretch">
137
+ <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'/>
139
+ <div className="pointer-events-none absolute left-1/2 top-[66%] -translate-x-1/2 -translate-y-1/2">
140
+ <div className="font-extrabold text-black text-3xl ">
141
+ {Math.round(score || 0)}%
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </div>
158
146
  </div>
159
147
  </div>
160
148
  );
@@ -11,6 +11,9 @@ import {
11
11
  YAxis,
12
12
  CartesianGrid,
13
13
  Tooltip,
14
+ ScatterChart,
15
+ Scatter,
16
+ ZAxis,
14
17
  } from 'recharts';
15
18
 
16
19
 
@@ -265,69 +268,31 @@ const Skills = ({ skillsCategoryRadar, headless }: { skillsMatrix?: SkillsMatrix
265
268
  }}
266
269
  >
267
270
  {(() => {
268
- const measured = containerWidth || (containerRef.current?.clientWidth || 0);
269
- const parentWidth = containerRef.current?.parentElement?.clientWidth || measured;
270
- const width = Math.max(0, Math.min(measured, parentWidth));
271
- const height = 300;
272
- const innerWidth = Math.max(0, width);
273
- const innerHeight = Math.max(0, height);
274
- const packed = packBubbles(combinedBubbleData, innerWidth, innerHeight, headless ? 10 : 6);
271
+ const data = combinedBubbleData.map((d, idx) => ({ x: (idx % 4) + 1, y: Math.floor(idx / 4) + 1, value: d.value, label: d.label }));
272
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
273
+ const BubbleTooltip = ({ active, payload }: any) => {
274
+ if (!active || !payload || !payload.length) return null;
275
+ const p = payload[0]?.payload;
276
+ return (
277
+ <div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6 }}>
278
+ <div style={{ fontWeight: 600 }}>{p?.label}</div>
279
+ <div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>Score: {p?.value}</div>
280
+ </div>
281
+ );
282
+ };
275
283
  return (
276
- <svg width={'100%'} height={height} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="xMidYMid meet">
277
- <g transform={headless ? 'translate(0, 0)' : 'translate(-25, -20)'}>
278
- {packed.map((b, idx) => {
279
- const clipId = `bubble-clip-${idx}`;
280
- const canShowText = b.r >= 14;
281
- const labelSpec = canShowText ? buildLabelLines(b.label, b.r) : { lines: [], fontSize: 10 };
282
- const lineGap = Math.max(2, Math.floor(labelSpec.fontSize * 0.2));
283
- const totalTextHeight = labelSpec.lines.length * labelSpec.fontSize + Math.max(0, (labelSpec.lines.length - 1) * lineGap);
284
- const startY = -Math.floor(totalTextHeight / 2) + Math.floor(labelSpec.fontSize * 0.85);
285
- return (
286
- <g
287
- key={idx}
288
- transform={`translate(${b.x},${b.y})`}
289
- onMouseEnter={(e) => {
290
- const rect = containerRef.current?.getBoundingClientRect();
291
- if (!rect) return;
292
- setFootprintChartTooltip({
293
- visible: true,
294
- x: e.clientX - rect.left + 12,
295
- y: e.clientY - rect.top + 12,
296
- title: b.label,
297
- body: `Score: ${b.value}`,
298
- });
299
- }}
300
- onMouseMove={(e) => {
301
- if (!footprintChartTooltip || !containerRef.current) return;
302
- const rect = containerRef.current.getBoundingClientRect();
303
- setFootprintChartTooltip({ ...footprintChartTooltip, x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12 });
304
- }}
305
- onMouseLeave={() => setFootprintChartTooltip(null)}
306
- >
307
- <defs>
308
- <clipPath id={clipId}>
309
- <circle r={b.r} />
310
- </clipPath>
311
- </defs>
312
- <circle r={b.r} fill={'var(--bubble-foreground)'} stroke={'var(--icon-button-secondary)'} />
313
- <title>{`${b.label}: ${b.value}`}</title>
314
- {canShowText && labelSpec.lines.length > 0 ? (
315
- <g clipPath={`url(#${clipId})`} style={{ pointerEvents: 'none' }}>
316
- {labelSpec.lines.map((line, li) => (
317
- <text key={li} y={startY + li * (labelSpec.fontSize + lineGap)} fontSize={labelSpec.fontSize} textAnchor="middle" fill={'var(--bubble-background)'}>
318
- {line}
319
- </text>
320
- ))}
321
- </g>
322
- ) : null}
323
- </g>
324
- );
325
- })}
326
- </g>
327
- </svg>
284
+ <ResponsiveContainer>
285
+ <ScatterChart margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
286
+ <CartesianGrid strokeDasharray="3 3" stroke={'var(--icon-button-secondary)'} />
287
+ <XAxis type="number" dataKey="x" tick={false} axisLine={false} domain={[0, 5]} />
288
+ <YAxis type="number" dataKey="y" tick={false} axisLine={false} domain={[0, Math.ceil(data.length / 4) + 1]} />
289
+ <ZAxis dataKey="value" range={[80, 360]} />
290
+ <Tooltip content={<BubbleTooltip />} cursor={{ stroke: 'var(--icon-button-secondary)' }} />
291
+ <Scatter data={data} fill={'var(--bubble-foreground)'} />
292
+ </ScatterChart>
293
+ </ResponsiveContainer>
328
294
  );
329
295
  })()}
330
- {!headless && <TooltipBox state={footprintChartTooltip} />}
331
296
  </div>
332
297
  {/* Legend */}
333
298
  <div className={'mt-3'}>
@@ -0,0 +1,181 @@
1
+ "use client";
2
+
3
+ import React, { useMemo, useRef, useState, useEffect } from 'react';
4
+ import BubbleChart from '@knowyourdeveloper/react-bubble-chart';
5
+
6
+ type SkillsRadarPoint = {
7
+ axis: string;
8
+ observed?: number;
9
+ self_reported?: number;
10
+ certified?: number;
11
+ experience?: number; // 0-100 saturation driver
12
+ };
13
+
14
+ type HoverTooltipState = {
15
+ visible: boolean;
16
+ x: number;
17
+ y: number;
18
+ title: string;
19
+ body?: string;
20
+ } | null;
21
+
22
+ const TooltipBox = ({ state }: { state: HoverTooltipState }) => {
23
+ if (!state || !state.visible) return null;
24
+ return (
25
+ <div
26
+ style={{
27
+ position: 'absolute',
28
+ left: state.x,
29
+ top: state.y,
30
+ pointerEvents: 'none',
31
+ background: 'var(--content-card-background)',
32
+ border: '1px solid var(--icon-button-secondary)',
33
+ color: 'var(--text-main)',
34
+ padding: 10,
35
+ borderRadius: 6,
36
+ minWidth: 250,
37
+ maxWidth: 320,
38
+ zIndex: 10,
39
+ }}
40
+ >
41
+ <div style={{ fontWeight: 600 }}>{state.title}</div>
42
+ {state.body ? (
43
+ <div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{state.body}</div>
44
+ ) : null}
45
+ </div>
46
+ );
47
+ };
48
+
49
+ export default function SkillsBubble({ skillsCategoryRadar, headless }: { skillsCategoryRadar?: SkillsRadarPoint[]; headless?: boolean }) {
50
+ const hasRadar = !!(skillsCategoryRadar && skillsCategoryRadar.length > 0);
51
+ const skillsRadarLimited = (skillsCategoryRadar || []).slice(0, 24);
52
+ const containerRef = useRef<HTMLDivElement>(null);
53
+ const legendRef = useRef<HTMLDivElement>(null);
54
+ const [legendTooltip, setLegendTooltip] = useState<HoverTooltipState>(null);
55
+
56
+ useEffect(() => {
57
+ if (typeof window !== 'undefined') {
58
+ const id = window.setTimeout(() => {
59
+ try { window.dispatchEvent(new Event('resize')); } catch {}
60
+ }, 0);
61
+ return () => window.clearTimeout(id);
62
+ }
63
+ }, []);
64
+
65
+ // ratio drives size: average of observed/self_reported/certified
66
+ const bubbles = useMemo(() => {
67
+ const seriesAvg = (d: SkillsRadarPoint): number => {
68
+ const vals = [Number(d.observed || 0), Number(d.self_reported || 0), Number(d.certified || 0)];
69
+ const nonZero = vals.filter((v) => v > 0);
70
+ const base = (nonZero.length > 0 ? nonZero : vals);
71
+ return Math.max(0, Math.min(100, Math.round(base.reduce((a, b) => a + b, 0) / (base.length || 1))));
72
+ };
73
+
74
+ const maxValue = Math.max(1, ...skillsRadarLimited.map(seriesAvg));
75
+
76
+ return skillsRadarLimited.map((d) => {
77
+ const value = seriesAvg(d);
78
+ const experience = Math.max(0, Math.min(100, Number(d.experience || 0)));
79
+ const size = Math.max(2, Math.round((value / maxValue) * 100)); // 2..100
80
+ // map experience to saturation in HSL; keep hue constant for brand-neutral look
81
+ const saturation = Math.max(20, Math.min(100, experience));
82
+ const color = `hsl(210 ${saturation}% 45%)`;
83
+ return {
84
+ label: d.axis,
85
+ value: size,
86
+ color,
87
+ tooltip: `${d.axis}\nRatio: ${value}\nExperience: ${experience}`,
88
+ data: { ratio: value, experience }
89
+ };
90
+ });
91
+ }, [skillsRadarLimited]);
92
+
93
+ const percentLegend = useMemo(() => {
94
+ const total = bubbles.reduce((sum, b) => sum + (b.data?.ratio || 0), 0);
95
+ return bubbles
96
+ .map((b) => ({ label: b.label, percent: total > 0 ? Math.round(((b.data?.ratio || 0) / total) * 100) : 0, experience: b.data?.experience || 0 }))
97
+ .sort((a, b) => b.percent - a.percent);
98
+ }, [bubbles]);
99
+
100
+ if (!hasRadar) return null;
101
+
102
+ return (
103
+ <div className={'kyd-avoid-break'}>
104
+ <div ref={containerRef} style={{ width: '100%', height: 340 }}>
105
+ <BubbleChart
106
+ width={'100%'}
107
+ height={'100%'}
108
+ graph={{ zoom: 1 }}
109
+ showLegend={false}
110
+ data={bubbles}
111
+ legend={false}
112
+ valueFont={{ family: 'system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, sans-serif', size: 11, color: 'var(--text-main)' }}
113
+ labelFont={{ family: 'system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, sans-serif', size: 12, color: 'var(--text-main)' }}
114
+ bubbleClickFun={(_evt: unknown, b: { label?: string }) => {
115
+ try {
116
+ if (typeof window !== 'undefined') {
117
+ const anchor = `#appendix-skills-cat-${encodeURIComponent(String(b?.label || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''))}`;
118
+ const url = `#appendix${anchor}`;
119
+ window.location.hash = url;
120
+ }
121
+ } catch {}
122
+ }}
123
+ tooltip
124
+ />
125
+ </div>
126
+ <div className={'mt-3'}>
127
+ <div ref={legendRef} className={'kyd-avoid-break'} style={{ position: 'relative', breakInside: 'avoid', pageBreakInside: 'avoid' as unknown as undefined }}>
128
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
129
+ {percentLegend.map((item, idx) => (
130
+ <button
131
+ key={idx}
132
+ className="flex items-center gap-2 text-xs text-left hover:underline underline-offset-2"
133
+ style={{ color: 'var(--text-secondary)', background: 'transparent' }}
134
+ onClick={() => {
135
+ try {
136
+ if (typeof window !== 'undefined') {
137
+ const anchor = `#appendix-skills-cat-${encodeURIComponent(item.label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''))}`;
138
+ const url = `#appendix${anchor}`;
139
+ window.location.hash = url;
140
+ }
141
+ } catch {}
142
+ }}
143
+ onMouseEnter={(e) => {
144
+ const rect = legendRef.current?.getBoundingClientRect();
145
+ if (!rect) return;
146
+ const x = e.clientX - rect.left + 12;
147
+ const y = e.clientY - rect.top + 12;
148
+ setLegendTooltip({ visible: true, x, y, title: item.label, body: `${item.label} • ${item.percent}% of ratio • Experience ${item.experience}` });
149
+ }}
150
+ onMouseMove={(e) => {
151
+ if (!legendTooltip || !legendRef.current) return;
152
+ const rect = legendRef.current.getBoundingClientRect();
153
+ setLegendTooltip({ ...legendTooltip, x: e.clientX - rect.left + 12, y: e.clientY - rect.top + 12 });
154
+ }}
155
+ onMouseLeave={() => setLegendTooltip(null)}
156
+ >
157
+ <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 }} />
158
+ <span className="truncate">{item.label}</span>
159
+ <span className="ml-auto opacity-80">{item.percent}%</span>
160
+ </button>
161
+ ))}
162
+ </div>
163
+ {!headless && <TooltipBox state={legendTooltip} />}
164
+ {/* Legends */}
165
+ <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs" style={{ color: 'var(--text-secondary)' }}>
166
+ <div className="flex items-center gap-2">
167
+ <span className="inline-block h-3 w-3 rounded-full" style={{ background: 'hsl(210 80% 45%)' }} />
168
+ <span>Bubble size: relative ratio of observed/self-reported/certified</span>
169
+ </div>
170
+ <div className="flex items-center gap-2">
171
+ <span className="inline-block h-3 w-3 rounded-full" style={{ background: 'hsl(210 20% 45%)' }} />
172
+ <span>Color saturation: experience (darker = more experienced)</span>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ </div>
178
+ );
179
+ }
180
+
181
+
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts';
5
+
6
+ type SkillsRadarPoint = {
7
+ axis: string;
8
+ observed?: number;
9
+ self_reported?: number;
10
+ certified?: number;
11
+ };
12
+
13
+ export default function SkillsValidation({ skillsCategoryRadar, headless }: { skillsCategoryRadar?: SkillsRadarPoint[]; headless?: boolean }) {
14
+ const skillsRadarLimited = (skillsCategoryRadar || []).slice(0, 8);
15
+
16
+ return (
17
+ <div className={'rounded-lg p-4 border kyd-avoid-break'} style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)', breakInside: 'avoid', pageBreakInside: 'avoid' as unknown as undefined }}>
18
+ <h4 className={'font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Skills by Validation Type</h4>
19
+ <p className={'text-sm mb-4'} style={{ color: 'var(--text-secondary)' }}>The bar chart shows how each skill is supported by self-attested claims, observed practice, or certified evidence.</p>
20
+ <div style={{ height: 360 }}>
21
+ <ResponsiveContainer>
22
+ <BarChart data={skillsRadarLimited} margin={{ top: 8, right: 8, left: 8, bottom: 36 }}>
23
+ <CartesianGrid strokeDasharray="3 3" stroke={'var(--icon-button-secondary)'} />
24
+ <XAxis dataKey="axis" tick={{ fill: 'var(--text-secondary)', fontSize: 12 }} interval={0} angle={-20} textAnchor="end" height={50} />
25
+ <YAxis domain={[0, 100]} tick={{ fill: 'var(--text-secondary)' }} />
26
+ <Tooltip contentStyle={{ background: 'var(--content-card-background)', border: `1px solid var(--icon-button-secondary)`, color: 'var(--text-main)' }} />
27
+ <Bar dataKey="observed" name="Observed" fill={'var(--bar-observed)'} isAnimationActive={!headless} />
28
+ <Bar dataKey="self_reported" name="Self-reported" fill={'var(--bar-self-reported)'} isAnimationActive={!headless} />
29
+ <Bar dataKey="certified" name="Certified" fill={'var(--bar-certified)'} isAnimationActive={!headless} />
30
+ </BarChart>
31
+ </ResponsiveContainer>
32
+ </div>
33
+ </div>
34
+ );
35
+ }
36
+
37
+
@@ -20,12 +20,10 @@ export default function SummaryCards({ graphInsights, assessmentResult, topBusin
20
20
  const riskLabel = uiRisk?.label || 'RISK';
21
21
  const riskTop = uiRisk?.top_movers && uiRisk.top_movers.length > 0 ? uiRisk.top_movers : topBusinessForGenre('Risk');
22
22
 
23
- const ai = assessmentResult?.ai_usage_summary;
24
- const aiLabel = 'AI Transparency';
25
- const aiTopMovers = ai?.key_findings || [];
23
+ // AI section removed per requirements
26
24
 
27
25
  return (
28
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 *:min-h-full">
26
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 *:min-h-full">
29
27
  <GaugeCard
30
28
  key={'technical-card'}
31
29
  title={'KYD Technical'}
@@ -44,15 +42,7 @@ export default function SummaryCards({ graphInsights, assessmentResult, topBusin
44
42
  topMoversTitle={'Top Score Movers'}
45
43
  tooltipText={'Higher bar filled indicates lower overall risk; movement to the right reflects improved risk posture.'}
46
44
  />
47
- <GaugeCard
48
- key={'ai-card'}
49
- title={'KYD AI (Beta)'}
50
- description={'Indicates the degree to which AI-assisted code is explicitly disclosed across analyzed files.'}
51
- percent={ai?.transparency_score}
52
- label={aiLabel}
53
- topMovers={(aiTopMovers).map(t => ({ label: t, uid: 'ai-usage' }))}
54
- topMoversTitle={'Key Findings'}
55
- />
45
+ {/* AI card removed */}
56
46
  </div>
57
47
  );
58
48
  }
@@ -3,7 +3,7 @@ import { useRouter } from 'next/navigation';
3
3
  import { ProviderIcon } from '../utils/provider';
4
4
  import { normalizeLinkedInInput } from './linkedin';
5
5
  import type { ConnectAccountsProps } from './types';
6
- import { CheckCircle, Link2, LinkIcon, Unlink, ArrowLeft, ExternalLink, Settings } from 'lucide-react';
6
+ import { CheckCircle, Link2, LinkIcon, Unlink, ArrowLeft, ExternalLink, Settings, Shield, InfoIcon } from 'lucide-react';
7
7
  import { AnimatePresence, motion } from 'framer-motion';
8
8
  import { Button, Input, Spinner, Card, CardHeader, CardContent, CardFooter, CardTitle } from '../ui';
9
9
  import Link from 'next/link';
@@ -38,7 +38,7 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
38
38
  headerDescription,
39
39
  requiredProviders,
40
40
  companyName,
41
- initialProviderId,
41
+ initialProviderId, // = process.env.NEXT_PUBLIC_STAGE === 'dev' ? 'githubapp' : undefined,
42
42
  githubAppSlugId,
43
43
  userId,
44
44
  inviteId,
@@ -50,6 +50,7 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
50
50
  const [isSubmitting, setIsSubmitting] = useState(false);
51
51
  const [isDisconnecting, setIsDisconnecting] = useState<string | null>(null);
52
52
  const [showGithubManage, setShowGithubManage] = useState(false);
53
+ const [showDataHandling, setShowDataHandling] = useState(false);
53
54
 
54
55
  const apiBase = apiGatewayUrl || (typeof process !== 'undefined' ? (process.env.NEXT_PUBLIC_API_GATEWAY_URL as string) : '');
55
56
  const connectedIds = useMemo(
@@ -197,6 +198,7 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
197
198
  setSelectedProviderIdAndCallback(null);
198
199
  setLinkUrl('');
199
200
  setShowGithubManage(false);
201
+ setShowDataHandling(false);
200
202
  };
201
203
 
202
204
  const cardVariants = {
@@ -204,6 +206,11 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
204
206
  animate: { opacity: 1, y: 0 },
205
207
  exit: { opacity: 0, y: -20 },
206
208
  };
209
+ const fadeOnly = {
210
+ initial: { opacity: 0 },
211
+ animate: { opacity: 1 },
212
+ exit: { opacity: 0 },
213
+ };
207
214
 
208
215
  // GitHub status helpers
209
216
  const githubConnectedAccount = useMemo(() => {
@@ -216,14 +223,189 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
216
223
  }, [githubConnectedAccount]);
217
224
 
218
225
  return (
219
- <>
220
- {selectedProvider && selectedProvider.id !== 'githubapp' ? (
221
- <AnimatePresence>
226
+ <AnimatePresence initial={false} mode="wait">
227
+ {showDataHandling ? (
228
+ <motion.div
229
+ key="data-handling-card"
230
+ className="rounded-xl border max-w-xl w-full"
231
+ initial="initial" animate="animate" exit="exit" variants={fadeOnly}
232
+ style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
233
+ >
234
+ <div className="sm:p-6 p-4">
235
+ <button onClick={() => setShowDataHandling(false)} className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors mb-4">
236
+ <ArrowLeft className="w-4 h-4" />
237
+ Back
238
+ </button>
239
+ <div className="text-center">
240
+ <div className="flex justify-center mb-4">
241
+ <InfoIcon className="w-10 h-10 text-[var(--text-main)]" />
242
+ </div>
243
+ <h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>How your data is handled</h3>
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.
246
+ </p>
247
+ <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{' '}
249
+ <Link href="https://www.knowyourdeveloper.ai/privacy-policy" target="_blank" rel="noopener noreferrer" className="underline" style={{ color: 'var(--icon-accent)'}}>
250
+ Privacy Policy <ExternalLink className="size-3 inline-block ml-1" />
251
+ </Link>.
252
+ </p>
253
+ </div>
254
+ </div>
255
+ </motion.div>
256
+ ) : selectedProvider && selectedProvider.id !== 'githubapp' ? (
257
+ <motion.div
258
+ key="connect-card"
259
+ initial="initial" animate="animate" exit="exit" variants={fadeOnly}
260
+ className="rounded-xl border max-w-xl w-full"
261
+ style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
262
+ >
263
+ <div className="sm:p-6 p-4">
264
+ <button onClick={handleConnectBack} className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors mb-4">
265
+ <ArrowLeft className="w-4 h-4" />
266
+ Back
267
+ </button>
268
+ <div className="text-center">
269
+ <div className="flex justify-center mb-4">
270
+ <ProviderIcon name={selectedProvider.id} className={`w-10 h-10 ${selectedProvider.iconColor || 'text-gray-500'}`} />
271
+ </div>
272
+ <h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>
273
+ {selectedProvider.connectionType === 'url' || (selectedProvider.connectionType || 'url') === 'link'
274
+ ? `Use Public ${selectedProvider.name} Profile`
275
+ : `Connect ${selectedProvider.name}`}
276
+ </h3>
277
+ <p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto">
278
+ {(selectedProvider.connectionType === 'url' || selectedProvider.connectionType === 'link')
279
+ ? (selectedProvider.placeholder || 'Enter your public profile URL.')
280
+ : `Authorize with ${selectedProvider.name} to connect your account.`}
281
+ </p>
282
+ </div>
283
+
284
+ {(selectedProvider.connectionType === 'url' || selectedProvider.connectionType === 'link') ? (
285
+ <motion.form
286
+ onSubmit={(e) => { e.preventDefault(); onSubmitLink(selectedProvider.id); }}
287
+ className="mt-6 space-y-4"
288
+ initial="initial" animate="animate" exit="exit" variants={cardVariants}
289
+ >
290
+ {selectedProvider.id === 'linkedin' && (
291
+ <p className="sm:text-xs items-center text-[10px] text-[var(--text-secondary)] leading-relaxed max-w-xs mx-auto -mt-2">
292
+ <Link
293
+ href="https://www.linkedin.com/public-profile/settings"
294
+ target="_blank"
295
+ rel="noopener noreferrer"
296
+ className="underline"
297
+ style={{ color: 'var(--icon-accent)' }}
298
+ >
299
+ LinkedIn <ExternalLink className="size-3 inline-block ml-1 underline-0" />
300
+ </Link>
301
+ . This opens your public profile settings (you’ll see your shareable URL if you’re signed in).
302
+ </p>
303
+ )}
304
+ <div className="relative">
305
+ <LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" />
306
+ <Input
307
+ type="url"
308
+ value={linkUrl}
309
+ onChange={(e) => setLinkUrl(e.target.value)}
310
+ placeholder={selectedProvider.placeholder || 'https://example.com/your-profile'}
311
+ required
312
+ className="w-full border bg-transparent p-2 pl-9"
313
+ style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
314
+ onPaste={selectedProvider.id === 'linkedin' ? (e) => { const text = e.clipboardData.getData('text'); setLinkUrl(normalizeLinkedInInput(text)); e.preventDefault(); } : undefined}
315
+ onBlur={selectedProvider.id === 'linkedin' ? (() => setLinkUrl(normalizeLinkedInInput(linkUrl))) : undefined}
316
+ />
317
+ </div>
318
+ <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
319
+ <Button type="submit" className="w-full bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)] transition-colors" disabled={isSubmitting}>
320
+ {isSubmitting ? (
321
+ <div className="flex items-center justify-center">
322
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
323
+ Connecting...
324
+ </div>
325
+ ) : (
326
+ 'Connect'
327
+ )}
328
+ </Button>
329
+ </motion.div>
330
+ </motion.form>
331
+ ) : (
332
+ <div className="mt-6">
333
+ <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
334
+ <Button onClick={() => onOAuth(selectedProvider.id)} className="w-full bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)] transition-colors">
335
+ <ExternalLink className="w-4 h-4 mr-2" />
336
+ Connect with {selectedProvider.name}
337
+ </Button>
338
+ </motion.div>
339
+ </div>
340
+ )}
341
+ </div>
342
+ </motion.div>
343
+ ) : selectedProvider && selectedProvider.id === 'githubapp' ? (
344
+ (!showGithubManage && initialProviderId === 'githubapp') ? (
345
+ <div
346
+ key="github-card"
347
+ className="rounded-xl border max-w-xl w-full"
348
+ style={{
349
+ backgroundColor: 'var(--content-card-background)',
350
+ borderColor: 'var(--icon-button-secondary)',
351
+ }}
352
+ >
353
+ <motion.div className="p-6 flex flex-col items-center" initial="initial" animate="animate" exit="exit" variants={fadeOnly}>
354
+ <div className="w-full flex items-center gap-3 mb-2 justify-center">
355
+ <ProviderIcon name="github" className="w-8 h-8 inline-block" />
356
+ <span className="sm:text-xl text-base font-semibold text-[var(--text-main)]">Connect Private GitHub Repositories</span>
357
+ </div>
358
+ <p className="sm:text-sm text-xs text-[var(--text-secondary)] leading-relaxed mt-1 mb-6 text-center max-w-md">
359
+ You&apos;ve successfully linked your GitHub account!
360
+ <br />
361
+ To complete your profile, you can optionally allow access to your <b>private repositories</b>. This is useful if you&apos;d like to highlight private work or share additional contributions for verification.
362
+ <br /><br />
363
+ <span className="text-[var(--text-main)] font-medium">
364
+ Would you like to connect your private repositories?
365
+ </span>
366
+ <button
367
+ type="button"
368
+ onClick={() => setShowDataHandling(true)}
369
+ className="sm:text-sm text-xs underline text-[var(--icon-accent)] hover:text-[var(--icon-accent-hover)]"
370
+ >
371
+ See how your data is handled
372
+ </button>
373
+ </p>
374
+ <div className="flex flex-col sm:flex-row w-full gap-3 mt-2 justify-center items-center">
375
+ <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
376
+ <Button
377
+ className="w-full sm:w-auto text-[var(--text-main)] transition-colors border border-[var(--icon-button-secondary)]"
378
+ variant="destructive"
379
+ onClick={() => {
380
+ handleConnectBack();
381
+ }}
382
+ >
383
+ No, don&#39;t connect
384
+ </Button>
385
+ </motion.div>
386
+ <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
387
+ <Button
388
+ className="w-full sm:w-auto bg-[var(--icon-accent)] text-white transition-colors font-semibold"
389
+ onClick={onGithubAppInstall}
390
+ >
391
+ <span className="flex items-center justify-center">
392
+ <ExternalLink className="w-4 h-4 mr-2" />
393
+ Yes, connect my private repos
394
+ </span>
395
+ </Button>
396
+ </motion.div>
397
+ </div>
398
+ </motion.div>
399
+ </div>
400
+ ) : (
222
401
  <motion.div
223
- key="connect-card"
402
+ key="github-manage"
224
403
  className="rounded-xl border max-w-xl w-full"
225
404
  style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
226
- initial="initial" animate="animate" exit="exit" variants={cardVariants}
405
+ variants={cardVariants}
406
+ initial="initial"
407
+ animate="animate"
408
+ exit="exit"
227
409
  >
228
410
  <div className="sm:p-6 p-4">
229
411
  <button onClick={handleConnectBack} className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors mb-4">
@@ -232,227 +414,85 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
232
414
  </button>
233
415
  <div className="text-center">
234
416
  <div className="flex justify-center mb-4">
235
- <ProviderIcon name={selectedProvider.id} className={`w-10 h-10 ${selectedProvider.iconColor || 'text-gray-500'}`} />
417
+ <ProviderIcon name="github" className="w-10 h-10" />
236
418
  </div>
237
- <h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>
238
- {selectedProvider.connectionType === 'url' || (selectedProvider.connectionType || 'url') === 'link'
239
- ? `Use Public ${selectedProvider.name} Profile`
240
- : `Connect ${selectedProvider.name}`}
241
- </h3>
242
- <p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto">
243
- {(selectedProvider.connectionType === 'url' || selectedProvider.connectionType === 'link')
244
- ? (selectedProvider.placeholder || 'Enter your public profile URL.')
245
- : `Authorize with ${selectedProvider.name} to connect your account.`}
419
+ <h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>Manage GitHub Connections</h3>
420
+ <p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md">
421
+ Connect or disconnect your GitHub OAuth account and optional GitHub App for private repositories.
246
422
  </p>
247
423
  </div>
248
424
 
249
- {(selectedProvider.connectionType === 'url' || selectedProvider.connectionType === 'link') ? (
250
- <motion.form
251
- onSubmit={(e) => { e.preventDefault(); onSubmitLink(selectedProvider.id); }}
252
- className="mt-6 space-y-4"
253
- initial="initial" animate="animate" exit="exit" variants={cardVariants}
254
- >
255
- {selectedProvider.id === 'linkedin' && (
256
- <p className="sm:text-xs items-center text-[10px] text-[var(--text-secondary)] leading-relaxed max-w-xs mx-auto -mt-2">
257
- <Link
258
- href="https://www.linkedin.com/public-profile/settings"
259
- target="_blank"
260
- rel="noopener noreferrer"
261
- className="underline"
262
- style={{ color: 'var(--icon-accent)' }}
263
- >
264
- LinkedIn <ExternalLink className="size-3 inline-block ml-1 underline-0" />
265
- </Link>
266
- . This opens your public profile settings (you’ll see your shareable URL if you’re signed in).
267
- </p>
268
- )}
269
- <div className="relative">
270
- <LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" />
271
- <Input
272
- type="url"
273
- value={linkUrl}
274
- onChange={(e) => setLinkUrl(e.target.value)}
275
- placeholder={selectedProvider.placeholder || 'https://example.com/your-profile'}
276
- required
277
- className="w-full border bg-transparent p-2 pl-9"
278
- style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
279
- onPaste={selectedProvider.id === 'linkedin' ? (e) => { const text = e.clipboardData.getData('text'); setLinkUrl(normalizeLinkedInInput(text)); e.preventDefault(); } : undefined}
280
- onBlur={selectedProvider.id === 'linkedin' ? (() => setLinkUrl(normalizeLinkedInInput(linkUrl))) : undefined}
281
- />
282
- </div>
283
- <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
284
- <Button type="submit" className="w-full bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)] transition-colors" disabled={isSubmitting}>
285
- {isSubmitting ? (
286
- <div className="flex items-center justify-center">
287
- <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
288
- Connecting...
289
- </div>
425
+ <div className="mt-6 space-y-4">
426
+ <div className="rounded-lg border p-4" style={{ borderColor: 'var(--icon-button-secondary)'}}>
427
+ <div className="flex items-center justify-between">
428
+ <div>
429
+ <div className="font-semibold sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>GitHub OAuth</div>
430
+ <div className="sm:text-sm text-xs" style={{ color: 'var(--text-secondary)'}}>{isGithubConnected ? 'Connected' : 'Not connected'}</div>
431
+ </div>
432
+ <div className="flex items-center gap-2">
433
+ {isGithubConnected ? (
434
+ <Button
435
+ onClick={() => onDisconnect('github')}
436
+ className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
437
+ style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
438
+ variant="destructive"
439
+ disabled={isDisconnecting === 'github'}
440
+ >
441
+ {isDisconnecting === 'github' ? <Spinner /> : <Unlink className="size-3 sm:size-4" />}
442
+ <span>Disconnect</span>
443
+ </Button>
290
444
  ) : (
291
- 'Connect'
445
+ <Button
446
+ onClick={() => onOAuth('github')}
447
+ className="bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)]"
448
+ >
449
+ <ExternalLink className="w-4 h-4 mr-2" />
450
+ Connect
451
+ </Button>
292
452
  )}
293
- </Button>
294
- </motion.div>
295
- </motion.form>
296
- ) : (
297
- <div className="mt-6">
298
- <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
299
- <Button onClick={() => onOAuth(selectedProvider.id)} className="w-full bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)] transition-colors">
300
- <ExternalLink className="w-4 h-4 mr-2" />
301
- Connect with {selectedProvider.name}
302
- </Button>
303
- </motion.div>
304
- </div>
305
- )}
306
- </div>
307
- </motion.div>
308
- </AnimatePresence>
309
- ) : selectedProvider && selectedProvider.id === 'githubapp' ? (
310
- <AnimatePresence>
311
- {(!showGithubManage && initialProviderId === 'githubapp') ? (
312
- <motion.div
313
- key="github-card"
314
- className="rounded-xl border max-w-xl w-full"
315
- style={{
316
- backgroundColor: 'var(--content-card-background)',
317
- borderColor: 'var(--icon-button-secondary)',
318
- }}
319
- variants={cardVariants}
320
- initial="initial"
321
- animate="animate"
322
- exit="exit"
323
- transition={{ duration: 0.3 }}
324
- >
325
- <div className="p-6 flex flex-col items-center">
326
- <div className="w-full flex items-center gap-3 mb-2 justify-center">
327
- <ProviderIcon name="github" className="w-8 h-8 inline-block" />
328
- <span className="sm:text-xl text-base font-semibold text-[var(--text-main)]">Connect Private GitHub Repositories</span>
329
- </div>
330
- <p className="sm:text-sm text-xs text-[var(--text-secondary)] leading-relaxed mt-1 mb-6 text-center max-w-md">
331
- You’ve successfully linked your GitHub account!
332
- <br />
333
- To complete your profile, you can optionally allow access to your <b>private repositories</b>. This is useful if you’d like to highlight private work or share additional contributions for verification.
334
- <br /><br />
335
- <span className="text-[var(--text-main)] font-medium">
336
- Would you like to connect your private repositories?
337
- </span>
338
- </p>
339
- <div className="flex flex-col sm:flex-row w-full gap-3 mt-2 justify-center items-center">
340
- <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
341
- <Button
342
- className="w-full sm:w-auto text-[var(--text-main)] transition-colors border border-[var(--icon-button-secondary)]"
343
- variant="destructive"
344
- onClick={() => {
345
- handleConnectBack();
346
- }}
347
- >
348
- No, don&#39;t connect
349
- </Button>
350
- </motion.div>
351
- <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
352
- <Button
353
- className="w-full sm:w-auto bg-[var(--icon-accent)] text-white transition-colors font-semibold"
354
- onClick={onGithubAppInstall}
355
- >
356
- <span className="flex items-center justify-center">
357
- <ExternalLink className="w-4 h-4 mr-2" />
358
- Yes, connect my private repos
359
- </span>
360
- </Button>
361
- </motion.div>
362
- </div>
363
- </div>
364
- </motion.div>
365
- ) : (
366
- <motion.div
367
- key="github-manage"
368
- className="rounded-xl border max-w-xl w-full"
369
- style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
370
- variants={cardVariants}
371
- initial="initial"
372
- animate="animate"
373
- exit="exit"
374
- >
375
- <div className="sm:p-6 p-4">
376
- <button onClick={handleConnectBack} className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors mb-4">
377
- <ArrowLeft className="w-4 h-4" />
378
- Back
379
- </button>
380
- <div className="text-center">
381
- <div className="flex justify-center mb-4">
382
- <ProviderIcon name="github" className="w-10 h-10" />
453
+ </div>
383
454
  </div>
384
- <h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>Manage GitHub Connections</h3>
385
- <p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md">
386
- Connect or disconnect your GitHub OAuth account and optional GitHub App for private repositories.
387
- </p>
388
455
  </div>
389
456
 
390
- <div className="mt-6 space-y-4">
391
- <div className="rounded-lg border p-4" style={{ borderColor: 'var(--icon-button-secondary)'}}>
392
- <div className="flex items-center justify-between">
393
- <div>
394
- <div className="font-semibold sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>GitHub OAuth</div>
395
- <div className="sm:text-sm text-xs" style={{ color: 'var(--text-secondary)'}}>{isGithubConnected ? 'Connected' : 'Not connected'}</div>
396
- </div>
397
- <div className="flex items-center gap-2">
398
- {isGithubConnected ? (
399
- <Button
400
- onClick={() => onDisconnect('github')}
401
- className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
402
- style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
403
- variant="destructive"
404
- disabled={isDisconnecting === 'github'}
405
- >
406
- {isDisconnecting === 'github' ? <Spinner /> : <Unlink className="size-3 sm:size-4" />}
407
- <span>Disconnect</span>
408
- </Button>
409
- ) : (
410
- <Button
411
- onClick={() => onOAuth('github')}
412
- className="bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)]"
413
- >
414
- <ExternalLink className="w-4 h-4 mr-2" />
415
- Connect
416
- </Button>
417
- )}
418
- </div>
457
+ <div className="rounded-lg border p-4" style={{ borderColor: 'var(--icon-button-secondary)'}}>
458
+ <div className="flex items-center justify-between">
459
+ <div>
460
+ <div className="font-semibold sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>GitHub App (Private Repos)</div>
461
+ <Button
462
+ className="inline-flex items-center text-xs font-medium text-[var(--icon-accent)] hover:text-[var(--icon-accent-hover)] transition-colors underline px-0 py-0 h-auto"
463
+ onClick={() => setShowDataHandling(true)}
464
+ variant="link"
465
+ >
466
+ How is my data handled?
467
+ </Button>
419
468
  </div>
420
- </div>
421
-
422
- <div className="rounded-lg border p-4" style={{ borderColor: 'var(--icon-button-secondary)'}}>
423
- <div className="flex items-center justify-between">
424
- <div>
425
- <div className="font-semibold sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>GitHub App (Private Repos)</div>
426
- <div className="sm:text-sm text-xs" style={{ color: 'var(--text-secondary)'}}>{isGithubAppInstalled ? 'Installed' : 'Not installed'}</div>
427
- </div>
428
- <div className="flex items-center gap-2">
429
- {isGithubAppInstalled ? (
430
- <Button
431
- onClick={() => { window.location.href = 'https://github.com/settings/installations'; }}
432
- className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
433
- style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
434
- variant="destructive"
435
- >
436
- <Unlink className="size-3 sm:size-4" />
437
- <span>Uninstall</span>
438
- </Button>
439
- ) : (
440
- <Button
441
- onClick={onGithubAppInstall}
442
- className="bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)]"
443
- >
444
- <ExternalLink className="w-4 h-4 mr-2" />
445
- Install
446
- </Button>
447
- )}
448
- </div>
469
+ <div className="flex items-center gap-2">
470
+ {isGithubAppInstalled ? (
471
+ <Button
472
+ onClick={() => { window.location.href = 'https://github.com/settings/installations'; }}
473
+ className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
474
+ style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
475
+ variant="destructive"
476
+ >
477
+ <Unlink className="size-3 sm:size-4" />
478
+ <span>Uninstall</span>
479
+ </Button>
480
+ ) : (
481
+ <Button
482
+ onClick={onGithubAppInstall}
483
+ className="bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)]"
484
+ >
485
+ <ExternalLink className="w-4 h-4 mr-2" />
486
+ Install
487
+ </Button>
488
+ )}
449
489
  </div>
450
490
  </div>
451
491
  </div>
452
492
  </div>
453
- </motion.div>
454
- )}
455
- </AnimatePresence>
493
+ </div>
494
+ </motion.div>
495
+ )
456
496
  ) : (
457
497
  <Card className="border-[var(--icon-button-secondary)] pt-2" style={{ backgroundColor: 'var(--content-card-background)'}}>
458
498
  <AnimatePresence mode="wait">
@@ -647,7 +687,7 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
647
687
  </AnimatePresence>
648
688
  </Card>
649
689
  )}
650
- </>
690
+ </AnimatePresence>
651
691
  );
652
692
  }
653
693
 
package/src/types.ts CHANGED
@@ -378,6 +378,8 @@ export interface GraphInsightsPayload {
378
378
  observed?: number; // 0-100
379
379
  self_reported?: number; // 0-100
380
380
  certified?: number; // 0-100
381
+ // New: experience metric (0-100) for color saturation
382
+ experience?: number;
381
383
  }>;
382
384
  // New: Flattened list of business rule selections (for appendix)
383
385
  business_rules_all?: Array<{