Skip to content

Commit 3822c70

Browse files
authored
feat: UI elevation -- accessibility, scroll/load performance, motion polish, token consistency (#185)
* fix: correctness bugs in counter, particles, constellation, dates, keys - AnimatedCounter: cancel rAF on unmount, support decimals (CGPA), drop redundant one-shot ref - ParticleField: regenerate positions on count change (useMemo) to avoid buffer desync - InteractiveConstellation: cancel rAF before restart so visibilitychange can't stack loops - parseDate: guard unknown month (?? not ||) and non-finite year - experienceHelpers: stable list keys for repeated bullet/skill strings * perf: cut scroll-time paint/GPU cost and oversized images - AuroraBlobs: animate transform (x/y) instead of left/top so the blurred background layer composites on the GPU instead of forcing per-frame layout - SceneBackground: pause the 3D frameloop during active scroll (resume at rest) via Lenis velocity; keeps motion visible where it reads - ShootingStars: fewer streaks on mobile (viewport-gated count) - CertBadge: request Credly /size/220x220 thumbnails (43KB->25KB each across 20 badges, sharper on retina) via render-time helper that survives the sync * refactor: route raw hex/rgba/easing literals through design tokens Replace hardcoded color/easing literals with the existing exports in src/constants/theme.ts (TEXT_*, CYAN/PURPLE/GREEN/AMBER, GLASS_*, EASING.cinematic) and --ch-* CSS channels across leaf section/card components. Byte-identical at runtime; removes the per-component drift the token system was meant to prevent. * feat: elevate UI with accessibility, mobile, motion and premium refinements Accessibility (WCAG 2.2): - skip-to-content link + main landmark; nav aria-label/current/expanded/controls - MobileMenu is now a real dialog: focus trap, Escape, body scroll-lock, focus return - focus-trap edge fix; contact form aria-invalid + inline error; success announced - modal titles h3->h2; bare-key shortcut guard; spinner label dedup State/UX correctness: - contact: preserve sender name for the success screen, surface non-200 send errors, keep error toast until dismissed; GitHub calendar timeout fallback + link Mobile/responsive: - 44px touch targets, single breakpoint source, responsive menu width - viewport-gated 3D scene (desktop only) and background layer counts Motion/perf: - once:true entrance reveals, press feedback, tabular-nums counters - replace .section-darker backdrop-blur with gradient (kills the dominant fast-scroll repaint); NavBar no longer transitions blur radius; glass blur 12->10px - remove cursor-following conic-gradient card border (calmer + cheaper per-frame paint) - Lenis tuned (lerp) for responsive fast scroll; all scroll buttons routed through Lenis Design system: - wire MAX_WIDTH/token scales into sections; SectionHeader/GlassCard/modals/Toast through tokens; TEXT_MUTED bumped for AA contrast; delete dead BLUE token * docs: changelog + version bump to 3.15.0 Document the accessibility, scroll/load performance, motion, and design-token refinements from this release. * fix(security): validate Credly host via URL parsing, not substring CodeQL js/incomplete-url-substring-sanitization (high): url.includes("images.credly.com") would match hostile URLs (images.credly.com.evil.com, ...?images.credly.com). Parse with new URL() and compare hostname exactly.
1 parent eec70d9 commit 3822c70

88 files changed

Lines changed: 743 additions & 418 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,35 @@
22

33
All notable changes to this project are documented here. Follows [Semantic Versioning](https://semver.org/).
44

5+
## [3.15.0] - 2026-06-28
6+
7+
### Added
8+
9+
- **Accessibility**: skip-to-content link, `main` landmark, nav `aria-label`/`aria-current`/`aria-expanded`/`aria-controls`, contact-form inline error with `aria-invalid` + `role="alert"`, screen-reader announcement on send success
10+
- **MobileMenu** is now a real dialog -- focus trap, Escape to close, body scroll-lock, focus return to the trigger (reuses `useFocusTrap`)
11+
- **GitHub calendar**: explicit timeout fallback with a link to the live profile when the contribution data is slow
12+
- `MAX_WIDTH_WIDE` / `MAX_WIDTH_FORM` layout tokens; `--ch-red` channel; `credlyThumb` render-time image-resize helper
13+
14+
### Changed
15+
16+
- **Performance (scroll)**: replaced the full-width `.section-darker` backdrop-blur bands with gradient overlays (the dominant fast-scroll repaint), animated AuroraBlobs via `transform` instead of `left/top`, paused the 3D frameloop during active scroll, gated the 3D scene to desktop, stopped transitioning the nav blur radius, reduced glass blur 12->10px
17+
- **Performance (load)**: Credly badges now request `/size/220x220` thumbnails (~43KB->25KB each across 20 badges, sharper on retina)
18+
- **Scroll feel**: Lenis tuned to lerp-based smoothing for responsive fast scrolling; all in-page scroll actions routed through Lenis; `overscroll-behavior` added
19+
- **Design system**: routed raw hex/rgba/easing literals through `theme.ts` tokens and `--ch-*` channels across the component tree; wired `MAX_WIDTH*`/type tokens into sections; bumped `TEXT_MUTED` for WCAG AA contrast; removed the cursor-following conic-gradient card border for a calmer, cheaper hover
20+
- **Motion**: `once: true` on entrance reveals (no replay on scroll-up), press feedback on key controls, `tabular-nums` on counters
21+
22+
### Fixed
23+
24+
- **AnimatedCounter**: cancel rAF on unmount, render decimal values correctly (e.g. CGPA), drop redundant one-shot ref
25+
- **ParticleField**: regenerate positions when `count` changes to avoid buffer-length desync
26+
- **InteractiveConstellation**: cancel rAF before restart so a `visibilitychange` cannot stack render loops
27+
- **Contact form**: preserve sender name for the success screen, surface non-200 send responses instead of failing silently
28+
- `parseDate` guards unknown months/years; stable React keys for repeated list strings
29+
30+
### Removed
31+
32+
- Dead `BLUE` color token
33+
534
## [3.14.0] - 2026-05-03
635

736
### Changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "portfolio-react",
3-
"version": "3.14.0",
3+
"version": "3.15.0",
44
"type": "module",
55
"private": true,
66
"homepage": "https://sagargupta.online/portfolio-react/",

src/App.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import InteractiveConstellation from "@components/ui/InteractiveConstellation";
1717
import SystemStatus from "@components/ui/SystemStatus";
1818
import { hasWebGL } from "@components/layout/Header/heroConstants";
1919
import { BreakpointProvider } from "@hooks/BreakpointProvider";
20+
import useBreakpoint from "@hooks/useBreakpoint";
2021

2122
// Global interactive 3D background, lazy-loaded so Three.js stays out of the
2223
// initial bundle. Falls back to the 2D constellation when WebGL is unavailable.
@@ -35,6 +36,7 @@ const GitHub = lazy(() => import("@pages/github/GitHub"));
3536

3637
const App = () => {
3738
const [webGLSupported] = useState(() => hasWebGL());
39+
const { isMobile } = useBreakpoint();
3840

3941
useEffect(() => {
4042
globalThis.history.scrollRestoration = "manual";
@@ -45,8 +47,13 @@ const App = () => {
4547
<ReactLenis
4648
root
4749
options={{
48-
duration: 1,
49-
easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
50+
// lerp-based smoothing (not a fixed duration) so a fast flick
51+
// resolves quickly instead of being forced through a full 1s curve --
52+
// keeps scrolling smooth but responsive when the user scrolls hard.
53+
lerp: 0.1,
54+
wheelMultiplier: 1.1,
55+
touchMultiplier: 1.5,
56+
syncTouch: false,
5057
}}
5158
>
5259
<BreakpointProvider>
@@ -56,7 +63,7 @@ const App = () => {
5663
<KeyboardNav />
5764
<AuroraBlobs />
5865
<ShootingStars />
59-
{webGLSupported ? (
66+
{webGLSupported && !isMobile ? (
6067
<ErrorBoundary fallback={<InteractiveConstellation />}>
6168
<Suspense fallback={null}>
6269
<SceneBackground />
@@ -67,8 +74,11 @@ const App = () => {
6774
)}
6875
<ParallaxElements />
6976
<div className="relative min-h-screen">
77+
<a href="#main-content" className="skip-link">
78+
Skip to content
79+
</a>
7080
<Nav />
71-
<main>
81+
<main id="main-content" tabIndex={-1}>
7282
<Hero />
7383
<Suspense fallback={<SectionLoader />}>
7484
<SectionTransition variant="gradient-sweep" />

src/components/3d/ParticleField.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable react/no-unknown-property */
2-
import { useEffect, useRef, useState } from "react";
2+
import { useEffect, useRef, useMemo } from "react";
33
import { useFrame } from "@react-three/fiber";
44
import * as THREE from "three";
55

@@ -20,7 +20,10 @@ interface ParticleFieldProps {
2020

2121
const ParticleField = ({ count = 300 }: ParticleFieldProps) => {
2222
const ref = useRef<THREE.Points>(null);
23-
const [positions] = useState(() => createParticlePositions(count));
23+
// Regenerate when count changes (degraded mode / breakpoint cross). A one-shot
24+
// useState would keep the old-length array while the bufferAttribute count
25+
// changed, reading past/short the buffer and collapsing particles.
26+
const positions = useMemo(() => createParticlePositions(count), [count]);
2427

2528
useFrame((_, delta) => {
2629
if (ref.current) {
@@ -47,7 +50,7 @@ const ParticleField = ({ count = 300 }: ParticleFieldProps) => {
4750
<bufferAttribute
4851
attach="attributes-position"
4952
args={[positions, 3]}
50-
count={count}
53+
count={positions.length / 3}
5154
/>
5255
</bufferGeometry>
5356
<pointsMaterial

src/components/3d/SceneBackground.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { useEffect, useRef, useState, type RefObject } from "react";
33
import { Canvas, useFrame } from "@react-three/fiber";
44
import { PerformanceMonitor } from "@react-three/drei";
5+
import { useLenis } from "lenis/react";
56
import * as THREE from "three";
67
import FloatingGeometry from "./FloatingGeometry";
78
import ParticleField from "./ParticleField";
@@ -65,7 +66,23 @@ const SceneBackground = () => {
6566
const { isMobile } = useBreakpoint();
6667
const reducedMotion = useReducedMotion();
6768
const [degraded, setDegraded] = useState(false);
69+
const [scrolling, setScrolling] = useState(false);
6870
const pointer = useRef<Pointer>({ x: 0, y: 0 });
71+
const scrollIdle = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
72+
73+
// Pause the render loop while the user is actively scrolling, resume ~160ms
74+
// after they stop. The 3D scene contributes nothing during a fast fling (it's
75+
// behind blurred glass and moving past) but competes for GPU/main thread with
76+
// the scroll -- freeing it is a direct fast-scroll smoothness win. Motion stays
77+
// fully visible at rest, which is when the parallax actually reads.
78+
useLenis(({ velocity }) => {
79+
if (Math.abs(velocity) < 0.05) return;
80+
if (!scrolling) setScrolling(true);
81+
clearTimeout(scrollIdle.current);
82+
scrollIdle.current = setTimeout(() => setScrolling(false), 160);
83+
});
84+
85+
useEffect(() => () => clearTimeout(scrollIdle.current), []);
6986

7087
useEffect(() => {
7188
const setFromXY = (clientX: number, clientY: number) => {
@@ -101,8 +118,10 @@ const SceneBackground = () => {
101118
}}
102119
camera={{ position: [0, 0, 8], fov: 50 }}
103120
dpr={dpr}
104-
// Reduced motion -> render once and freeze (static 3D snapshot).
105-
frameloop={reducedMotion ? "demand" : "always"}
121+
// Reduced motion -> render once and freeze. While actively scrolling ->
122+
// pause the loop ("demand") to free the GPU/main thread for the scroll;
123+
// resume "always" at rest.
124+
frameloop={reducedMotion || scrolling ? "demand" : "always"}
106125
gl={{
107126
antialias: !isMobile,
108127
alpha: true,

src/components/layout/Footer/Footer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useEffect, useCallback } from "react";
22
import { motion } from "motion/react";
33
import { staggerContainer } from "@utils/animations";
4-
import { MONO_FONT } from "@/constants/theme";
4+
import { MONO_FONT, CYAN } from "@/constants/theme";
55
import FooterContent from "./FooterContent";
66

77
const KONAMI: string[] = [
@@ -93,7 +93,7 @@ const Footer = () => {
9393
border: "1px solid rgba(6,182,212,0.3)",
9494
fontFamily: MONO_FONT,
9595
fontSize: 12,
96-
color: "#06b6d4",
96+
color: CYAN,
9797
textAlign: "center",
9898
}}
9999
>

src/components/layout/Footer/FooterContent.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useMemo } from "react";
22
import { motion } from "motion/react";
33
import { getName, getSiteConfig } from "@data/dataLoader";
44
import { staggerItem } from "@utils/animations";
5-
import { MONO_FONT } from "@/constants/theme";
5+
import { MONO_FONT, CYAN, TEXT_MUTED } from "@/constants/theme";
66
import useBreakpoint from "@hooks/useBreakpoint";
77
import FooterSocial from "./FooterSocial";
88

@@ -21,7 +21,7 @@ const FooterContent = () => {
2121
fontFamily: MONO_FONT,
2222
fontSize: 24,
2323
fontWeight: 700,
24-
color: "#06b6d4",
24+
color: CYAN,
2525
}}
2626
variants={staggerItem}
2727
>
@@ -56,7 +56,7 @@ const FooterContent = () => {
5656
key={tech}
5757
style={{
5858
fontSize: 10,
59-
color: "#6e6e90",
59+
color: TEXT_MUTED,
6060
fontFamily: MONO_FONT,
6161
padding: "2px 8px",
6262
borderRadius: 4,
@@ -81,7 +81,7 @@ const FooterContent = () => {
8181
>
8282
<p
8383
style={{
84-
color: "#6e6e90",
84+
color: TEXT_MUTED,
8585
fontSize: 14,
8686
textAlign: "center",
8787
}}

src/components/layout/Footer/FooterSocial.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useMemo } from "react";
22
import { motion } from "motion/react";
33
import { getSocialProfiles } from "@data/dataLoader";
44
import { staggerItem } from "@utils/animations";
5+
import { TEXT_SECONDARY, CYAN } from "@/constants/theme";
56
import ICON_MAP from "@utils/iconMap";
67

78
const FooterSocial = () => {
@@ -32,18 +33,18 @@ const FooterSocial = () => {
3233
display: "flex",
3334
alignItems: "center",
3435
justifyContent: "center",
35-
color: "#a5a5c0",
36+
color: TEXT_SECONDARY,
3637
transition: "all 0.3s",
3738
}}
3839
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => {
39-
e.currentTarget.style.color = "#06b6d4";
40+
e.currentTarget.style.color = CYAN;
4041
e.currentTarget.style.borderColor =
4142
"rgba(6, 182, 212, 0.3)";
4243
e.currentTarget.style.background =
4344
"rgba(6, 182, 212, 0.08)";
4445
}}
4546
onMouseLeave={(e: React.MouseEvent<HTMLAnchorElement>) => {
46-
e.currentTarget.style.color = "#a5a5c0";
47+
e.currentTarget.style.color = TEXT_SECONDARY;
4748
e.currentTarget.style.borderColor =
4849
"rgba(255, 255, 255, 0.06)";
4950
e.currentTarget.style.background =

src/components/layout/Header/Hero.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { useCallback } from "react";
22
import { motion } from "motion/react";
3+
import { useLenis } from "lenis/react";
34
import { ChevronDown } from "lucide-react";
45
import HeroContent from "./HeroContent";
56

67
const Hero = () => {
8+
const lenis = useLenis();
79
const scrollToAbout = useCallback(() => {
8-
const el = document.querySelector("#about");
9-
if (el) el.scrollIntoView({ behavior: "smooth" });
10-
}, []);
10+
const el = document.getElementById("about");
11+
if (!el) return;
12+
if (lenis) lenis.scrollTo(el, { offset: -64 });
13+
else el.scrollIntoView();
14+
}, [lenis]);
1115

1216
return (
1317
<section

src/components/layout/Header/HeroContent.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { useState, useEffect, useMemo, useCallback } from "react";
22
import { motion, AnimatePresence } from "motion/react";
3+
import { useLenis } from "lenis/react";
34
import { getName, getRoles } from "@data/dataLoader";
45
import { staggerContainer, staggerItem } from "@utils/animations";
5-
import { MONO_FONT } from "@/constants/theme";
6+
import { MONO_FONT, GREEN } from "@/constants/theme";
67
import HeroStats from "./HeroStats";
78
import HeroSocial from "./HeroSocial";
89
const RESUME_URL =
@@ -21,10 +22,13 @@ const HeroContent = () => {
2122
return () => clearInterval(interval);
2223
}, [roles]);
2324

25+
const lenis = useLenis();
2426
const scrollToProjects = useCallback(() => {
25-
const el = document.querySelector("#projects");
26-
if (el) el.scrollIntoView({ behavior: "smooth" });
27-
}, []);
27+
const el = document.getElementById("projects");
28+
if (!el) return;
29+
if (lenis) lenis.scrollTo(el, { offset: -64 });
30+
else el.scrollIntoView();
31+
}, [lenis]);
2832

2933
return (
3034
<motion.div
@@ -42,7 +46,7 @@ const HeroContent = () => {
4246
gap: 8,
4347
fontFamily: MONO_FONT,
4448
fontSize: 14,
45-
color: "#22c55e",
49+
color: GREEN,
4650
background: "rgba(34, 197, 94, 0.06)",
4751
backdropFilter: "blur(12px)",
4852
WebkitBackdropFilter: "blur(12px)",

0 commit comments

Comments
 (0)