kyd-shared-badge 0.2.29 → 0.2.31

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.29",
3
+ "version": "0.2.31",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -20,7 +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 AnimateOnMount from './components/AnimateOnMount';
23
+ import Reveal from './components/Reveal';
24
24
 
25
25
  // const hexToRgba = (hex: string, alpha: number) => {
26
26
  // const clean = hex.replace('#', '');
@@ -139,7 +139,7 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
139
139
  <BusinessRulesProvider items={graphInsights?.business_rules_all}>
140
140
  <div className={`${wrapperMaxWidth} mx-auto`}>
141
141
  {/* Share controls removed; app-level pages render their own actions */}
142
- <AnimateOnMount delayMs={40} offsetY={8}>
142
+ <Reveal offsetY={8} durationMs={500}>
143
143
  <ReportHeader
144
144
  badgeId={badgeId}
145
145
  developerName={badgeData.developerName}
@@ -150,89 +150,84 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
150
150
  summary={report_summary}
151
151
  countries={(assessmentResult?.screening_sources?.ip_risk_analysis?.raw_data?.countries) || []}
152
152
  />
153
- </AnimateOnMount>
154
- <AnimateOnMount delayMs={80} offsetY={10}>
155
- <div
156
- className={'rounded-xl shadow-xl p-6 sm:p-8 mt-8 border'}
157
- style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}
158
- >
153
+ </Reveal>
154
+ <div
155
+ className={'rounded-xl shadow-xl p-6 sm:p-8 mt-8 border'}
156
+ style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)' }}
157
+ >
159
158
 
160
159
  <div className={'space-y-12 divide-y'} style={{ borderColor: 'var(--icon-button-secondary)' }}>
161
160
  <div className="pt-8 first:pt-0">
162
- <h4 className={'text-2xl font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Report Summary</h4>
163
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
164
- {/* Technical semicircle gauge (refactored) */}
165
- {(() => {
166
- const ui = graphInsights?.uiSummary?.technical || {};
167
- const pct = Math.round(Number(ui?.percent ?? 0));
168
- const label = ui?.label || 'EVIDENCE';
169
- const top = ui?.top_movers && ui.top_movers.length > 0 ? ui.top_movers : topBusinessForGenre('Technical');
170
- return (
171
- <AnimateOnMount delayMs={120} offsetY={8}>
172
- <GaugeCard
173
- key={'technical-card'}
174
- title={'KYD Technical'}
175
- description={'The gauge visualization shows a weighted composite of technical evidence, with rightward movement indicating stronger indications of developer capability'}
176
- percent={pct}
177
- label={label}
178
- topMovers={top?.map(t => ({ label: t?.label, uid: t?.uid }))}
179
- topMoversTitle={'Top Score Movers'}
180
- />
181
- </AnimateOnMount>
182
- );
183
- })()}
184
-
185
- {/* Risk descending bars card (abstracted) */}
186
- {(() => {
187
- const ui = graphInsights?.uiSummary?.risk || {};
188
- const pctGood = Math.round(Number(ui?.percent_good ?? 0));
189
- const label = ui?.label || 'RISK';
190
- const top = ui?.top_movers && ui.top_movers.length > 0 ? ui.top_movers : topBusinessForGenre('Risk');
191
- const tooltip = 'Higher bar filled indicates lower overall risk; movement to the right reflects improved risk posture.';
192
- return (
193
- <AnimateOnMount delayMs={160} offsetY={8}>
194
- <RiskCard
195
- title={'KYD Risk'}
196
- description={'The bar chart visualizes relative risk levels, where shorter bars denote lower risk and taller bars indicate greater exposure.'}
197
- percentGood={pctGood}
198
- label={label}
199
- topMovers={top?.map(t => ({ label: t?.label, uid: t?.uid }))}
200
- topMoversTitle={'Top Score Movers'}
201
- tooltipText={tooltip}
202
- />
203
- </AnimateOnMount>
204
- );
205
- })()}
206
-
207
- {/* AI transparency semicircle gauge */}
208
- {(() => {
209
- const ai_usage_summary = assessmentResult?.ai_usage_summary;
210
- const label = 'AI Transparency'// TODO: calculate label frontend
211
- const topMovers = ai_usage_summary?.key_findings || []
212
- return (
213
- <AnimateOnMount delayMs={200} offsetY={8}>
214
- <GaugeCard
215
- key={'ai-card'}
216
- title={'KYD AI'}
217
- description={'Indicates the degree to which AI-assisted code is explicitly disclosed across analyzed files.'}
218
- percent={ai_usage_summary?.transparency_score}
219
- label={label}
220
- // id non-functional
221
- topMovers={topMovers.map(t => ({ label: t, uid: 'ai-usage' }))}
222
- topMoversTitle={'Key Findings'}
223
- />
224
- </AnimateOnMount>
225
- );
226
- })()}
227
- </div>
161
+ <Reveal as={'h4'} offsetY={8} durationMs={500} className={'text-2xl font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Report Summary</Reveal>
162
+ <Reveal as={'div'} offsetY={8} durationMs={500} className={'space-y-12 divide-y'} style={{ borderColor: 'var(--icon-button-secondary)' }}>
163
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 *:min-h-full">
164
+ {/* Technical semicircle gauge (refactored) */}
165
+ {(() => {
166
+ const ui = graphInsights?.uiSummary?.technical || {};
167
+ const pct = Math.round(Number(ui?.percent ?? 0));
168
+ const label = ui?.label || 'EVIDENCE';
169
+ const top = ui?.top_movers && ui.top_movers.length > 0 ? ui.top_movers : topBusinessForGenre('Technical');
170
+ return (
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
+ );
181
+ })()}
182
+
183
+ {/* Risk descending bars card (abstracted) */}
184
+ {(() => {
185
+ const ui = graphInsights?.uiSummary?.risk || {};
186
+ const pctGood = Math.round(Number(ui?.percent_good ?? 0));
187
+ const label = ui?.label || 'RISK';
188
+ const top = ui?.top_movers && ui.top_movers.length > 0 ? ui.top_movers : topBusinessForGenre('Risk');
189
+ const tooltip = 'Higher bar filled indicates lower overall risk; movement to the right reflects improved risk posture.';
190
+ return (
191
+ <RiskCard
192
+ title={'KYD Risk'}
193
+ description={'The bar chart visualizes relative risk levels, where shorter bars denote lower risk and taller bars indicate greater exposure.'}
194
+ percentGood={pctGood}
195
+ label={label}
196
+ topMovers={top?.map(t => ({ label: t?.label, uid: t?.uid }))}
197
+ topMoversTitle={'Top Score Movers'}
198
+ tooltipText={tooltip}
199
+ />
200
+ );
201
+ })()}
202
+
203
+ {/* AI transparency semicircle gauge */}
204
+ {(() => {
205
+ const ai_usage_summary = assessmentResult?.ai_usage_summary;
206
+ const label = 'AI Transparency'// TODO: calculate label frontend
207
+ const topMovers = ai_usage_summary?.key_findings || []
208
+ return (
209
+ <GaugeCard
210
+ key={'ai-card'}
211
+ title={'KYD AI'}
212
+ description={'Indicates the degree to which AI-assisted code is explicitly disclosed across analyzed files.'}
213
+ percent={ai_usage_summary?.transparency_score}
214
+ label={label}
215
+ // id non-functional
216
+ topMovers={topMovers.map(t => ({ label: t, uid: 'ai-usage' }))}
217
+ topMoversTitle={'Key Findings'}
218
+ />
219
+ );
220
+ })()}
221
+ </div>
222
+ </Reveal>
228
223
  </div>
229
224
 
230
225
  {/* Technical Scores */}
231
226
  <div className="mt-8" >
232
227
  <div key={'Technical'} className='pt-8 space-y-8' style={{ borderColor: 'var(--icon-button-secondary)'}}>
233
- <h4 className={'text-2xl font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>KYD Technical</h4>
228
+ <Reveal as={'h4'} offsetY={8} className={'text-2xl font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>KYD Technical</Reveal>
234
229
  {/* technical graph insights */}
235
- <AnimateOnMount delayMs={120} offsetY={8}>
230
+ <Reveal>
236
231
  <div className="">
237
232
  <GraphInsights
238
233
  graphInsights={graphInsights}
@@ -241,14 +236,13 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
241
236
  scoringSummary={scoringSummary}
242
237
  />
243
238
  </div>
244
- </AnimateOnMount>
239
+ </Reveal>
245
240
 
246
241
  {/* category bars and contributing factors */}
247
- <AnimateOnMount delayMs={160} offsetY={10}>
248
242
  <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)'}}>
249
243
 
250
244
  {/* Left: Bars */}
251
- <div className="lg:col-span-8 h-full">
245
+ <Reveal className="lg:col-span-8 h-full">
252
246
  <CategoryBars
253
247
  title={'Technical Category Contributions - Percentages'}
254
248
  categories={genreMapping?.['Technical'] as string[]}
@@ -257,10 +251,10 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
257
251
  getCategoryTooltipCopy={getCategoryTooltipCopy}
258
252
  barHeight={16}
259
253
  />
260
- </div>
254
+ </Reveal>
261
255
 
262
256
  {/* Right: Contributing Factors */}
263
- <div className="lg:col-span-4 w-full ml-20 flex flex-col items-start justify-start">
257
+ <Reveal className="lg:col-span-4 w-full ml-20 flex flex-col items-start justify-start" delayMs={80}>
264
258
  <div className={'text-sm font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Top Contributing Factors</div>
265
259
  <div className="space-y-4">
266
260
  {(genreMapping?.['Technical'] || []).map((cat: string) => {
@@ -289,30 +283,28 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
289
283
  );
290
284
  })}
291
285
  </div>
292
- </div>
286
+ </Reveal>
293
287
  </div>
294
- </AnimateOnMount>
295
288
 
296
- <AnimateOnMount delayMs={180} offsetY={10}>
289
+ <Reveal>
297
290
  <div className="pt-8 border-t" style={{ borderColor: 'var(--icon-button-secondary)'}}>
298
291
  <h3 className={'text-xl font-bold mb-3'} style={{ color: 'var(--text-main)' }}>KYD Technical - Skills Insights</h3>
299
292
  <div className={'prose prose-sm max-w-none mb-6 space-y-4'} style={{ color: 'var(--text-secondary)' }}>
300
293
  <Skills skillsMatrix={skillsMatrix} skillsCategoryRadar={graphInsights?.skillsCategoryRadar} />
301
294
  </div>
302
295
  </div>
303
- </AnimateOnMount>
296
+ </Reveal>
304
297
 
305
298
  </div>
306
299
  </div>
307
300
 
308
301
 
309
302
 
310
- <AnimateOnMount delayMs={200} offsetY={10}>
311
303
  <div className="pt-8 space-y-8">
312
- <h3 className={'text-2xl font-bold'} style={{ color: 'var(--text-main)' }}>KYD Risk - Overview</h3>
304
+ <Reveal as={'h3'} offsetY={8} className={'text-2xl font-bold'} style={{ color: 'var(--text-main)' }}>KYD Risk - Overview</Reveal>
313
305
 
314
306
  {/* Risk Graph Insights and Category Bars */}
315
- <AnimateOnMount delayMs={220} offsetY={8}>
307
+ <Reveal>
316
308
  <div className="">
317
309
  <GraphInsights
318
310
  graphInsights={graphInsights}
@@ -321,11 +313,10 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
321
313
  scoringSummary={scoringSummary}
322
314
  />
323
315
  </div>
324
- </AnimateOnMount>
325
- <AnimateOnMount delayMs={240} offsetY={10}>
316
+ </Reveal>
326
317
  <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)' }}>
327
318
  {/* Left: Bars */}
328
- <div className="lg:col-span-8 h-full">
319
+ <Reveal className="lg:col-span-8 h-full">
329
320
  <CategoryBars
330
321
  title={'KYD Risk - Category Insights'}
331
322
  categories={genreMapping?.['Risk'] as string[]}
@@ -334,9 +325,9 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
334
325
  getCategoryTooltipCopy={getCategoryTooltipCopy}
335
326
  barHeight={16}
336
327
  />
337
- </div>
328
+ </Reveal>
338
329
  {/* Right: Contributing Factors */}
339
- <div className="lg:col-span-4 w-full ml-20 flex flex-col items-start justify-start">
330
+ <Reveal className="lg:col-span-4 w-full ml-20 flex flex-col items-start justify-start" delayMs={80}>
340
331
  <div className={'text-sm font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Top Contributing Factors</div>
341
332
  <div className="space-y-4">
342
333
  {genreMapping?.['Risk']?.map((cat: string) => {
@@ -365,13 +356,12 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
365
356
  );
366
357
  })}
367
358
  </div>
368
- </div>
359
+ </Reveal>
369
360
  </div>
370
- </AnimateOnMount>
371
361
 
372
362
  {/* cyber risk display */}
373
363
  {badgeData.optOutScreening ? (
374
- <div className={'p-4 rounded-lg border'} style={{ backgroundColor: 'var(--icon-button-secondary)', borderColor: 'var(--icon-button-secondary)' }}>
364
+ <Reveal className={'p-4 rounded-lg border'} style={{ backgroundColor: 'var(--icon-button-secondary)', borderColor: 'var(--icon-button-secondary)' }}>
375
365
  <div className="flex items-start">
376
366
  <span className="h-5 w-5 mr-3 mt-0.5 flex-shrink-0" style={{ color: yellow }}>
377
367
  <FiAlertTriangle size={20} />
@@ -383,7 +373,7 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
383
373
  </p>
384
374
  </div>
385
375
  </div>
386
- </div>
376
+ </Reveal>
387
377
  ) : (
388
378
  <>
389
379
  {(() => {
@@ -393,8 +383,7 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
393
383
  const fbiMatches = ss?.fbi_matches && (ss.fbi_matches.length > 0);
394
384
  if (!(ofacMatches || cslDetails || fbiMatches)) return null;
395
385
  return (
396
- <AnimateOnMount delayMs={260} offsetY={8}>
397
- <div className={'mb-8 rounded-lg border p-4'} style={{ borderColor: 'var(--icon-button-secondary)', backgroundColor: 'var(--content-card-background)' }}>
386
+ <Reveal className={'mb-8 rounded-lg border p-4'} style={{ borderColor: 'var(--icon-button-secondary)', backgroundColor: 'var(--content-card-background)' }}>
398
387
  <h4 className={'text-lg font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>3A. Sanctions Matches</h4>
399
388
  {/* OFAC matches */}
400
389
  {ofacMatches && (
@@ -454,41 +443,38 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
454
443
  </div>
455
444
  </div>
456
445
  )}
457
- </div>
458
- </AnimateOnMount>
446
+ </Reveal>
459
447
  );
460
448
  })()}
461
- <AnimateOnMount delayMs={280} offsetY={8}>
449
+ <Reveal>
462
450
  <IpRiskAnalysisDisplay ipRiskAnalysis={screening_sources?.ip_risk_analysis} />
463
- </AnimateOnMount>
451
+ </Reveal>
464
452
  </>
465
453
  )}
466
454
 
467
455
  </div>
468
- </AnimateOnMount>
469
456
 
470
457
  {/* Connected Platforms */}
471
- <AnimateOnMount delayMs={260} offsetY={10}>
458
+ <Reveal>
472
459
  <ConnectedPlatforms accounts={connected} />
473
- </AnimateOnMount>
460
+ </Reveal>
474
461
 
475
462
 
476
- <AnimateOnMount delayMs={280} offsetY={10}>
477
463
  <div className="pt-8">
478
464
  <h3 className={'text-2xl font-bold mb-4'} style={{ color: 'var(--text-main)' }}>Appendix: Data Sources</h3>
479
465
  <div className="space-y-8">
480
466
 
481
467
  {/* Skills */}
482
- <AnimateOnMount delayMs={300} offsetY={8}>
468
+ <Reveal>
483
469
  <div>
484
470
  <h4 id="appendix-skills" className={'text-lg font-bold mb-4'} style={{ color: 'var(--text-main)' }}>Skills</h4>
485
471
  <SkillsAppendixTable skillsAll={skillsAll} />
486
472
  </div>
487
- </AnimateOnMount>
473
+ </Reveal>
488
474
 
489
475
  {/* Observations */}
490
476
  {Array.isArray(graphInsights?.business_rules_all) && graphInsights.business_rules_all.length > 0 && (
491
- <AnimateOnMount delayMs={320} offsetY={8}>
477
+ <Reveal>
492
478
  <div>
493
479
  <h4 className={'text-lg font-bold mb-4'} style={{ color: 'var(--text-main)' }}>Observations</h4>
494
480
  <AppendixTables
@@ -499,12 +485,12 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
499
485
  developerName={developerName || 'this developer'}
500
486
  />
501
487
  </div>
502
- </AnimateOnMount>
488
+ </Reveal>
503
489
  )}
504
490
 
505
491
  {/* Sanctions & Watchlists */}
506
492
  {!badgeData.optOutScreening && screening_sources && (
507
- <AnimateOnMount delayMs={340} offsetY={8}>
493
+ <Reveal>
508
494
  <div>
509
495
  <h4 className={'text-lg font-bold mb-4'} style={{ color: 'var(--text-main)' }}>Sanctions & Watchlists</h4>
510
496
  {(() => {
@@ -527,15 +513,14 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
527
513
  />
528
514
  );
529
515
  })()}
530
- </div>
531
- </AnimateOnMount>
516
+ </div>
517
+ </Reveal>
532
518
  )}
533
519
 
534
520
  </div>
535
521
  </div>
536
- </AnimateOnMount>
537
522
 
538
- <AnimateOnMount delayMs={300} offsetY={6}>
523
+ <Reveal>
539
524
  <div className={'pt-8 text-sm text-center'} style={{ color: 'var(--text-secondary)' }}>
540
525
  Report Completed: {new Date(updatedAt).toLocaleString(undefined, {
541
526
  year: 'numeric',
@@ -546,15 +531,16 @@ const SharedBadgeDisplay = ({ badgeData }: { badgeData: PublicBadgeData }) => {
546
531
  timeZoneName: 'short',
547
532
  })}
548
533
  </div>
549
- </AnimateOnMount>
534
+ </Reveal>
550
535
  </div>
551
536
  </div>
552
- </AnimateOnMount>
553
- <footer className={'mt-12 pt-6 border-t'} style={{ borderColor: 'var(--icon-button-secondary)' }}>
554
- <p className={'text-center text-xs max-w-4xl mx-auto'} style={{ color: 'var(--text-secondary)' }}>
555
- © 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.
556
- </p>
557
- </footer>
537
+ <Reveal>
538
+ <footer className={'mt-12 pt-6 border-t'} style={{ borderColor: 'var(--icon-button-secondary)' }}>
539
+ <p className={'text-center text-xs max-w-4xl mx-auto'} style={{ color: 'var(--text-secondary)' }}>
540
+ © 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.
541
+ </p>
542
+ </footer>
543
+ </Reveal>
558
544
  </div>
559
545
  </BusinessRulesProvider>
560
546
  );
@@ -34,7 +34,7 @@ export default function GaugeCard({
34
34
 
35
35
  return (
36
36
  <div
37
- className={'rounded-md p-5 border flex flex-col'}
37
+ className={'rounded-md p-5 border flex flex-col min-h-full'}
38
38
  style={{
39
39
  backgroundColor: 'var(--content-card-background)',
40
40
  borderColor: 'var(--icon-button-secondary)',
@@ -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?: React.ElementType;
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
+
@@ -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
+
@@ -1,66 +0,0 @@
1
- 'use client';
2
-
3
- import React from 'react';
4
-
5
- type AnimateOnMountProps = {
6
- children: React.ReactNode;
7
- delayMs?: number;
8
- durationMs?: number;
9
- offsetY?: number; // positive moves up during animation
10
- className?: string;
11
- style?: React.CSSProperties;
12
- as?: 'div' | 'section' | 'span' | 'header' | 'footer' | 'main' | 'article' | 'aside' | 'nav';
13
- };
14
-
15
- const AnimateOnMount: React.FC<AnimateOnMountProps> = ({
16
- children,
17
- delayMs = 60,
18
- durationMs = 420,
19
- offsetY = 10,
20
- className,
21
- style,
22
- as = 'div',
23
- }) => {
24
- const [mounted, setMounted] = React.useState(false);
25
- const reduceMotion = React.useMemo(() => {
26
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
27
- try {
28
- return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
29
- } catch {
30
- return false;
31
- }
32
- }, []);
33
-
34
- React.useEffect(() => {
35
- if (reduceMotion) {
36
- // Render immediately with no animation
37
- setMounted(true);
38
- return;
39
- }
40
- const id = requestAnimationFrame(() => setMounted(true));
41
- return () => cancelAnimationFrame(id);
42
- }, [reduceMotion]);
43
-
44
- const Component = as as any;
45
- const transition = `opacity ${durationMs}ms ease, transform ${durationMs}ms ease`;
46
-
47
- return (
48
- <Component
49
- className={className}
50
- style={{
51
- opacity: mounted ? 1 : 0,
52
- transform: mounted || reduceMotion ? 'translateY(0px)' : `translateY(${offsetY}px)`,
53
- transition: reduceMotion ? undefined : transition,
54
- transitionDelay: reduceMotion ? undefined : `${delayMs}ms`,
55
- willChange: reduceMotion ? undefined : 'opacity, transform',
56
- ...style,
57
- }}
58
- >
59
- {children}
60
- </Component>
61
- );
62
- };
63
-
64
- export default AnimateOnMount;
65
-
66
-