kyd-shared-badge 0.2.28 → 0.2.30

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.2.28",
3
+ "version": "0.2.30",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -20,6 +20,7 @@ import Skills from './components/Skills';
20
20
  import CategoryBars from './components/CategoryBars';
21
21
  import SkillsAppendixTable from './components/SkillsAppendixTable';
22
22
  import { BusinessRulesProvider } from './components/BusinessRulesContext';
23
+ import Reveal from './components/Reveal';
23
24
 
24
25
  // const hexToRgba = (hex: string, alpha: number) => {
25
26
  // const clean = hex.replace('#', '');
@@ -138,16 +139,18 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
138
139
  <BusinessRulesProvider items={graphInsights?.business_rules_all}>
139
140
  <div className={`${wrapperMaxWidth} mx-auto`}>
140
141
  {/* Share controls removed; app-level pages render their own actions */}
141
- <ReportHeader
142
- badgeId={badgeId}
143
- developerName={badgeData.developerName}
144
- updatedAt={updatedAt}
145
- score={overallFinalPercent || 0}
146
- isPublic={true}
147
- badgeImageUrl={badgeData.badgeImageUrl || ''}
148
- summary={report_summary}
149
- countries={(assessmentResult?.screening_sources?.ip_risk_analysis?.raw_data?.countries) || []}
150
- />
142
+ <Reveal offsetY={8} durationMs={500}>
143
+ <ReportHeader
144
+ badgeId={badgeId}
145
+ developerName={badgeData.developerName}
146
+ updatedAt={updatedAt}
147
+ score={overallFinalPercent || 0}
148
+ isPublic={true}
149
+ badgeImageUrl={badgeData.badgeImageUrl || ''}
150
+ summary={report_summary}
151
+ countries={(assessmentResult?.screening_sources?.ip_risk_analysis?.raw_data?.countries) || []}
152
+ />
153
+ </Reveal>
151
154
  <div
152
155
  className={'rounded-xl shadow-xl p-6 sm:p-8 mt-8 border'}
153
156
  style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}
@@ -155,7 +158,7 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
155
158
 
156
159
  <div className={'space-y-12 divide-y'} style={{ borderColor: 'var(--icon-button-secondary)' }}>
157
160
  <div className="pt-8 first:pt-0">
158
- <h4 className={'text-2xl font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Report Summary</h4>
161
+ <Reveal as={'h4'} offsetY={8} durationMs={500} className={'text-2xl font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Report Summary</Reveal>
159
162
  <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
160
163
  {/* Technical semicircle gauge (refactored) */}
161
164
  {(() => {
@@ -164,15 +167,17 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
164
167
  const label = ui?.label || 'EVIDENCE';
165
168
  const top = ui?.top_movers && ui.top_movers.length > 0 ? ui.top_movers : topBusinessForGenre('Technical');
166
169
  return (
167
- <GaugeCard
168
- key={'technical-card'}
169
- title={'KYD Technical'}
170
- description={'The gauge visualization shows a weighted composite of technical evidence, with rightward movement indicating stronger indications of developer capability'}
171
- percent={pct}
172
- label={label}
173
- topMovers={top?.map(t => ({ label: t?.label, uid: t?.uid }))}
174
- topMoversTitle={'Top Score Movers'}
175
- />
170
+ <Reveal delayMs={0} offsetY={12}>
171
+ <GaugeCard
172
+ key={'technical-card'}
173
+ title={'KYD Technical'}
174
+ description={'The gauge visualization shows a weighted composite of technical evidence, with rightward movement indicating stronger indications of developer capability'}
175
+ percent={pct}
176
+ label={label}
177
+ topMovers={top?.map(t => ({ label: t?.label, uid: t?.uid }))}
178
+ topMoversTitle={'Top Score Movers'}
179
+ />
180
+ </Reveal>
176
181
  );
177
182
  })()}
178
183
 
@@ -184,15 +189,17 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
184
189
  const top = ui?.top_movers && ui.top_movers.length > 0 ? ui.top_movers : topBusinessForGenre('Risk');
185
190
  const tooltip = 'Higher bar filled indicates lower overall risk; movement to the right reflects improved risk posture.';
186
191
  return (
187
- <RiskCard
188
- title={'KYD Risk'}
189
- description={'The bar chart visualizes relative risk levels, where shorter bars denote lower risk and taller bars indicate greater exposure.'}
190
- percentGood={pctGood}
191
- label={label}
192
- topMovers={top?.map(t => ({ label: t?.label, uid: t?.uid }))}
193
- topMoversTitle={'Top Score Movers'}
194
- tooltipText={tooltip}
195
- />
192
+ <Reveal delayMs={80} offsetY={12}>
193
+ <RiskCard
194
+ title={'KYD Risk'}
195
+ description={'The bar chart visualizes relative risk levels, where shorter bars denote lower risk and taller bars indicate greater exposure.'}
196
+ percentGood={pctGood}
197
+ label={label}
198
+ topMovers={top?.map(t => ({ label: t?.label, uid: t?.uid }))}
199
+ topMoversTitle={'Top Score Movers'}
200
+ tooltipText={tooltip}
201
+ />
202
+ </Reveal>
196
203
  );
197
204
  })()}
198
205
 
@@ -202,16 +209,18 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
202
209
  const label = 'AI Transparency'// TODO: calculate label frontend
203
210
  const topMovers = ai_usage_summary?.key_findings || []
204
211
  return (
205
- <GaugeCard
206
- key={'ai-card'}
207
- title={'KYD AI'}
208
- description={'Indicates the degree to which AI-assisted code is explicitly disclosed across analyzed files.'}
209
- percent={ai_usage_summary?.transparency_score}
210
- label={label}
211
- // id non-functional
212
- topMovers={topMovers.map(t => ({ label: t, uid: 'ai-usage' }))}
213
- topMoversTitle={'Key Findings'}
214
- />
212
+ <Reveal delayMs={160} offsetY={12}>
213
+ <GaugeCard
214
+ key={'ai-card'}
215
+ title={'KYD AI'}
216
+ description={'Indicates the degree to which AI-assisted code is explicitly disclosed across analyzed files.'}
217
+ percent={ai_usage_summary?.transparency_score}
218
+ label={label}
219
+ // id non-functional
220
+ topMovers={topMovers.map(t => ({ label: t, uid: 'ai-usage' }))}
221
+ topMoversTitle={'Key Findings'}
222
+ />
223
+ </Reveal>
215
224
  );
216
225
  })()}
217
226
  </div>
@@ -220,22 +229,24 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
220
229
  {/* Technical Scores */}
221
230
  <div className="mt-8" >
222
231
  <div key={'Technical'} className='pt-8 space-y-8' style={{ borderColor: 'var(--icon-button-secondary)'}}>
223
- <h4 className={'text-2xl font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>KYD Technical</h4>
232
+ <Reveal as={'h4'} offsetY={8} className={'text-2xl font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>KYD Technical</Reveal>
224
233
  {/* technical graph insights */}
225
- <div className="">
226
- <GraphInsights
227
- graphInsights={graphInsights}
228
- categories={genreMapping?.['Technical'] as string[]}
229
- genre={'Technical'}
230
- scoringSummary={scoringSummary}
231
- />
232
- </div>
234
+ <Reveal>
235
+ <div className="">
236
+ <GraphInsights
237
+ graphInsights={graphInsights}
238
+ categories={genreMapping?.['Technical'] as string[]}
239
+ genre={'Technical'}
240
+ scoringSummary={scoringSummary}
241
+ />
242
+ </div>
243
+ </Reveal>
233
244
 
234
245
  {/* category bars and contributing factors */}
235
246
  <div className="grid grid-cols-1 lg:grid-cols-12 w-full gap-8 items-stretch py-8 border-t" style={{ borderColor: 'var(--icon-button-secondary)'}}>
236
247
 
237
248
  {/* Left: Bars */}
238
- <div className="lg:col-span-8 h-full">
249
+ <Reveal className="lg:col-span-8 h-full">
239
250
  <CategoryBars
240
251
  title={'Technical Category Contributions - Percentages'}
241
252
  categories={genreMapping?.['Technical'] as string[]}
@@ -244,10 +255,10 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
244
255
  getCategoryTooltipCopy={getCategoryTooltipCopy}
245
256
  barHeight={16}
246
257
  />
247
- </div>
258
+ </Reveal>
248
259
 
249
260
  {/* Right: Contributing Factors */}
250
- <div className="lg:col-span-4 w-full ml-20 flex flex-col items-start justify-start">
261
+ <Reveal className="lg:col-span-4 w-full ml-20 flex flex-col items-start justify-start" delayMs={80}>
251
262
  <div className={'text-sm font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Top Contributing Factors</div>
252
263
  <div className="space-y-4">
253
264
  {(genreMapping?.['Technical'] || []).map((cat: string) => {
@@ -276,15 +287,17 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
276
287
  );
277
288
  })}
278
289
  </div>
279
- </div>
290
+ </Reveal>
280
291
  </div>
281
292
 
282
- <div className="pt-8 border-t" style={{ borderColor: 'var(--icon-button-secondary)'}}>
283
- <h3 className={'text-xl font-bold mb-3'} style={{ color: 'var(--text-main)' }}>KYD Technical - Skills Insights</h3>
284
- <div className={'prose prose-sm max-w-none mb-6 space-y-4'} style={{ color: 'var(--text-secondary)' }}>
285
- <Skills skillsMatrix={skillsMatrix} skillsCategoryRadar={graphInsights?.skillsCategoryRadar} />
293
+ <Reveal>
294
+ <div className="pt-8 border-t" style={{ borderColor: 'var(--icon-button-secondary)'}}>
295
+ <h3 className={'text-xl font-bold mb-3'} style={{ color: 'var(--text-main)' }}>KYD Technical - Skills Insights</h3>
296
+ <div className={'prose prose-sm max-w-none mb-6 space-y-4'} style={{ color: 'var(--text-secondary)' }}>
297
+ <Skills skillsMatrix={skillsMatrix} skillsCategoryRadar={graphInsights?.skillsCategoryRadar} />
298
+ </div>
286
299
  </div>
287
- </div>
300
+ </Reveal>
288
301
 
289
302
  </div>
290
303
  </div>
@@ -292,20 +305,22 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
292
305
 
293
306
 
294
307
  <div className="pt-8 space-y-8">
295
- <h3 className={'text-2xl font-bold'} style={{ color: 'var(--text-main)' }}>KYD Risk - Overview</h3>
308
+ <Reveal as={'h3'} offsetY={8} className={'text-2xl font-bold'} style={{ color: 'var(--text-main)' }}>KYD Risk - Overview</Reveal>
296
309
 
297
310
  {/* Risk Graph Insights and Category Bars */}
298
- <div className="">
299
- <GraphInsights
300
- graphInsights={graphInsights}
301
- categories={genreMapping?.['Risk'] as string[]}
302
- genre={'Risk'}
303
- scoringSummary={scoringSummary}
304
- />
305
- </div>
311
+ <Reveal>
312
+ <div className="">
313
+ <GraphInsights
314
+ graphInsights={graphInsights}
315
+ categories={genreMapping?.['Risk'] as string[]}
316
+ genre={'Risk'}
317
+ scoringSummary={scoringSummary}
318
+ />
319
+ </div>
320
+ </Reveal>
306
321
  <div className="grid grid-cols-1 lg:grid-cols-12 gap-8 w-full items-stretch py-8 border-y" style={{ borderColor: 'var(--icon-button-secondary)' }}>
307
322
  {/* Left: Bars */}
308
- <div className="lg:col-span-8 h-full">
323
+ <Reveal className="lg:col-span-8 h-full">
309
324
  <CategoryBars
310
325
  title={'KYD Risk - Category Insights'}
311
326
  categories={genreMapping?.['Risk'] as string[]}
@@ -314,9 +329,9 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
314
329
  getCategoryTooltipCopy={getCategoryTooltipCopy}
315
330
  barHeight={16}
316
331
  />
317
- </div>
332
+ </Reveal>
318
333
  {/* Right: Contributing Factors */}
319
- <div className="lg:col-span-4 w-full ml-20 flex flex-col items-start justify-start">
334
+ <Reveal className="lg:col-span-4 w-full ml-20 flex flex-col items-start justify-start" delayMs={80}>
320
335
  <div className={'text-sm font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Top Contributing Factors</div>
321
336
  <div className="space-y-4">
322
337
  {genreMapping?.['Risk']?.map((cat: string) => {
@@ -345,12 +360,12 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
345
360
  );
346
361
  })}
347
362
  </div>
348
- </div>
363
+ </Reveal>
349
364
  </div>
350
365
 
351
366
  {/* cyber risk display */}
352
367
  {badgeData.optOutScreening ? (
353
- <div className={'p-4 rounded-lg border'} style={{ backgroundColor: 'var(--icon-button-secondary)', borderColor: 'var(--icon-button-secondary)' }}>
368
+ <Reveal className={'p-4 rounded-lg border'} style={{ backgroundColor: 'var(--icon-button-secondary)', borderColor: 'var(--icon-button-secondary)' }}>
354
369
  <div className="flex items-start">
355
370
  <span className="h-5 w-5 mr-3 mt-0.5 flex-shrink-0" style={{ color: yellow }}>
356
371
  <FiAlertTriangle size={20} />
@@ -362,7 +377,7 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
362
377
  </p>
363
378
  </div>
364
379
  </div>
365
- </div>
380
+ </Reveal>
366
381
  ) : (
367
382
  <>
368
383
  {(() => {
@@ -372,7 +387,7 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
372
387
  const fbiMatches = ss?.fbi_matches && (ss.fbi_matches.length > 0);
373
388
  if (!(ofacMatches || cslDetails || fbiMatches)) return null;
374
389
  return (
375
- <div className={'mb-8 rounded-lg border p-4'} style={{ borderColor: 'var(--icon-button-secondary)', backgroundColor: 'var(--content-card-background)' }}>
390
+ <Reveal className={'mb-8 rounded-lg border p-4'} style={{ borderColor: 'var(--icon-button-secondary)', backgroundColor: 'var(--content-card-background)' }}>
376
391
  <h4 className={'text-lg font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>3A. Sanctions Matches</h4>
377
392
  {/* OFAC matches */}
378
393
  {ofacMatches && (
@@ -432,17 +447,21 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
432
447
  </div>
433
448
  </div>
434
449
  )}
435
- </div>
450
+ </Reveal>
436
451
  );
437
452
  })()}
438
- <IpRiskAnalysisDisplay ipRiskAnalysis={screening_sources?.ip_risk_analysis} />
453
+ <Reveal>
454
+ <IpRiskAnalysisDisplay ipRiskAnalysis={screening_sources?.ip_risk_analysis} />
455
+ </Reveal>
439
456
  </>
440
457
  )}
441
458
 
442
459
  </div>
443
460
 
444
461
  {/* Connected Platforms */}
445
- <ConnectedPlatforms accounts={connected} />
462
+ <Reveal>
463
+ <ConnectedPlatforms accounts={connected} />
464
+ </Reveal>
446
465
 
447
466
 
448
467
  <div className="pt-8">
@@ -450,30 +469,35 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
450
469
  <div className="space-y-8">
451
470
 
452
471
  {/* Skills */}
453
- <div>
454
- <h4 id="appendix-skills" className={'text-lg font-bold mb-4'} style={{ color: 'var(--text-main)' }}>Skills</h4>
455
- <SkillsAppendixTable skillsAll={skillsAll} />
456
- </div>
472
+ <Reveal>
473
+ <div>
474
+ <h4 id="appendix-skills" className={'text-lg font-bold mb-4'} style={{ color: 'var(--text-main)' }}>Skills</h4>
475
+ <SkillsAppendixTable skillsAll={skillsAll} />
476
+ </div>
477
+ </Reveal>
457
478
 
458
479
  {/* Observations */}
459
480
  {Array.isArray(graphInsights?.business_rules_all) && graphInsights.business_rules_all.length > 0 && (
460
- <div>
461
- <h4 className={'text-lg font-bold mb-4'} style={{ color: 'var(--text-main)' }}>Observations</h4>
462
- <AppendixTables
463
- type="business_rules"
464
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
465
- sources={graphInsights.business_rules_all as any}
466
- searchedAt={updatedAt}
467
- developerName={developerName || 'this developer'}
468
- />
469
- </div>
481
+ <Reveal>
482
+ <div>
483
+ <h4 className={'text-lg font-bold mb-4'} style={{ color: 'var(--text-main)' }}>Observations</h4>
484
+ <AppendixTables
485
+ type="business_rules"
486
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
487
+ sources={graphInsights.business_rules_all as any}
488
+ searchedAt={updatedAt}
489
+ developerName={developerName || 'this developer'}
490
+ />
491
+ </div>
492
+ </Reveal>
470
493
  )}
471
494
 
472
495
  {/* Sanctions & Watchlists */}
473
496
  {!badgeData.optOutScreening && screening_sources && (
474
- <div>
475
- <h4 className={'text-lg font-bold mb-4'} style={{ color: 'var(--text-main)' }}>Sanctions & Watchlists</h4>
476
- {(() => {
497
+ <Reveal>
498
+ <div>
499
+ <h4 className={'text-lg font-bold mb-4'} style={{ color: 'var(--text-main)' }}>Sanctions & Watchlists</h4>
500
+ {(() => {
477
501
  const ofacProvided = screening_sources.ofac_screen?.sources || [];
478
502
  const lists = [...(screening_sources.ofac_lists || []), ...ofacProvided];
479
503
  const seen: { [k: string]: boolean } = {};
@@ -493,29 +517,34 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
493
517
  />
494
518
  );
495
519
  })()}
496
- </div>
520
+ </div>
521
+ </Reveal>
497
522
  )}
498
523
 
499
524
  </div>
500
525
  </div>
501
526
 
502
- <div className={'pt-8 text-sm text-center'} style={{ color: 'var(--text-secondary)' }}>
503
- Report Completed: {new Date(updatedAt).toLocaleString(undefined, {
504
- year: 'numeric',
505
- month: 'long',
506
- day: 'numeric',
507
- hour: 'numeric',
508
- minute: '2-digit',
509
- timeZoneName: 'short',
510
- })}
511
- </div>
527
+ <Reveal>
528
+ <div className={'pt-8 text-sm text-center'} style={{ color: 'var(--text-secondary)' }}>
529
+ Report Completed: {new Date(updatedAt).toLocaleString(undefined, {
530
+ year: 'numeric',
531
+ month: 'long',
532
+ day: 'numeric',
533
+ hour: 'numeric',
534
+ minute: '2-digit',
535
+ timeZoneName: 'short',
536
+ })}
537
+ </div>
538
+ </Reveal>
512
539
  </div>
513
540
  </div>
514
- <footer className={'mt-12 pt-6 border-t'} style={{ borderColor: 'var(--icon-button-secondary)' }}>
515
- <p className={'text-center text-xs max-w-4xl mx-auto'} style={{ color: 'var(--text-secondary)' }}>
516
- © 2025 Know Your Developer, LLC. All rights reserved. KYD Self-Check™, and associated marks are trademarks of Know Your Developer, LLC. This document is confidential, proprietary, and intended solely for the individual or entity to whom it is addressed. Unauthorized use, disclosure, copying, or distribution of this document or any of its contents is strictly prohibited and may be unlawful. Know Your Developer, LLC assumes no responsibility or liability for any errors or omissions contained herein. Report validity subject to the terms and conditions stated on the official Know Your Developer website located at https://knowyourdeveloper.ai.
517
- </p>
518
- </footer>
541
+ <Reveal>
542
+ <footer className={'mt-12 pt-6 border-t'} style={{ borderColor: 'var(--icon-button-secondary)' }}>
543
+ <p className={'text-center text-xs max-w-4xl mx-auto'} style={{ color: 'var(--text-secondary)' }}>
544
+ © 2025 Know Your Developer, LLC. All rights reserved. KYD Self-Check™, and associated marks are trademarks of Know Your Developer, LLC. This document is confidential, proprietary, and intended solely for the individual or entity to whom it is addressed. Unauthorized use, disclosure, copying, or distribution of this document or any of its contents is strictly prohibited and may be unlawful. Know Your Developer, LLC assumes no responsibility or liability for any errors or omissions contained herein. Report validity subject to the terms and conditions stated on the official Know Your Developer website located at https://knowyourdeveloper.ai.
545
+ </p>
546
+ </footer>
547
+ </Reveal>
519
548
  </div>
520
549
  </BusinessRulesProvider>
521
550
  );
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import React, { CSSProperties, PropsWithChildren } from 'react';
4
+ import { useInViewOnce } from './useInViewOnce';
5
+
6
+ type RevealProps = PropsWithChildren<{
7
+ as?: keyof JSX.IntrinsicElements;
8
+ /** px to translate on Y axis initially */
9
+ offsetY?: number;
10
+ /** px to translate on X axis initially (rarely needed) */
11
+ offsetX?: number;
12
+ /** ms */
13
+ durationMs?: number;
14
+ /** ms */
15
+ delayMs?: number;
16
+ /** Optional className passed to the wrapper */
17
+ className?: string;
18
+ /** Optional style merged into wrapper */
19
+ style?: CSSProperties;
20
+ /** Intersection thresholds */
21
+ threshold?: number | number[];
22
+ /** Root margin for preloading */
23
+ rootMargin?: string;
24
+ }>;
25
+
26
+ const Reveal: React.FC<RevealProps> = ({
27
+ as = 'div',
28
+ offsetY = 12,
29
+ offsetX = 0,
30
+ durationMs = 500,
31
+ delayMs = 0,
32
+ className,
33
+ style,
34
+ threshold,
35
+ rootMargin,
36
+ children,
37
+ }) => {
38
+ const Tag = as as any;
39
+ const { ref, hasIntersected } = useInViewOnce({ threshold, rootMargin });
40
+
41
+ // Respect reduced motion is handled in the hook by triggering immediately.
42
+ const baseTransform = `translate(${offsetX}px, ${offsetY}px)`;
43
+ const eased = 'cubic-bezier(0.22, 1, 0.36, 1)'; // subtle spring-ish ease
44
+
45
+ const inlineStyles: CSSProperties = {
46
+ opacity: hasIntersected ? 1 : 0,
47
+ transform: hasIntersected ? 'none' : baseTransform,
48
+ transition: `opacity ${durationMs}ms ${eased} ${delayMs}ms, transform ${durationMs}ms ${eased} ${delayMs}ms`,
49
+ willChange: hasIntersected ? undefined : 'opacity, transform',
50
+ ...style,
51
+ };
52
+
53
+ return (
54
+ <Tag ref={ref} className={className} style={inlineStyles}>
55
+ {children}
56
+ </Tag>
57
+ );
58
+ };
59
+
60
+ export default Reveal;
61
+
62
+
@@ -26,9 +26,9 @@ export default function RiskCard({
26
26
  const pctGood = Math.max(0, Math.min(100, Math.round(Number(percentGood ?? 0))));
27
27
  const displayLabel = label || '';
28
28
 
29
- // bar heights descending representation
30
- const bars = [140, 110, 85, 60, 40];
31
- let activeIndex = 0; // Default to the tallest bar (highest risk)
29
+ // bar heights ascending representation
30
+ const bars = [40, 60, 85, 110, 140];
31
+ let activeIndex = 0; // Default to the shortest bar (highest risk)
32
32
  if (pctGood >= 80) {
33
33
  activeIndex = 4;
34
34
  } else if (pctGood >= 60) {
@@ -0,0 +1,58 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ /**
6
+ * Observes the provided element and returns true the first time it enters the viewport.
7
+ * Uses IntersectionObserver under the hood and disconnects after first intersection.
8
+ */
9
+ export function useInViewOnce<T extends HTMLElement>(options?: IntersectionObserverInit) {
10
+ const targetRef = useRef<T | null>(null);
11
+ const [hasIntersected, setHasIntersected] = useState<boolean>(false);
12
+ const [hasMounted, setHasMounted] = useState<boolean>(false);
13
+
14
+ useEffect(() => {
15
+ setHasMounted(true);
16
+ }, []);
17
+
18
+ useEffect(() => {
19
+ if (hasIntersected) return; // already observed once
20
+ const element = targetRef.current;
21
+ if (!element) return;
22
+
23
+ // Respect reduced motion
24
+ const prefersReducedMotion = typeof window !== 'undefined' &&
25
+ window.matchMedia &&
26
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
27
+ if (prefersReducedMotion) {
28
+ setHasIntersected(true);
29
+ return;
30
+ }
31
+
32
+ const observer = new IntersectionObserver(
33
+ (entries, obs) => {
34
+ for (const entry of entries) {
35
+ if (entry.isIntersecting) {
36
+ setHasIntersected(true);
37
+ obs.disconnect();
38
+ break;
39
+ }
40
+ }
41
+ },
42
+ {
43
+ root: options?.root || null,
44
+ rootMargin: options?.rootMargin ?? '0px 0px -10% 0px',
45
+ threshold: options?.threshold ?? 0.1,
46
+ }
47
+ );
48
+
49
+ observer.observe(element);
50
+ return () => {
51
+ observer.disconnect();
52
+ };
53
+ }, [options?.root, options?.rootMargin, options?.threshold, hasIntersected]);
54
+
55
+ return { ref: targetRef, isVisible: hasIntersected || !hasMounted ? false : hasIntersected, hasIntersected } as const;
56
+ }
57
+
58
+