fix: stabilize portfolio scrolling and loading
Some checks failed
Test and deploy / Verify production build (push) Has been cancelled
Test and deploy / Deploy production (push) Has been cancelled

Remove skeleton swaps and deferred layout estimates, restore reveal state, and use native proximity scrolling for project cards. Also make the Docker build npm registry configurable.
This commit is contained in:
2026-09-09 17:53:48 +03:30
parent f34bea05cd
commit b3cfcfe057
27 changed files with 180 additions and 567 deletions

View File

@@ -2,8 +2,12 @@ FROM node:22-alpine AS builder
WORKDIR /app
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/
COPY package.json package-lock.json ./
RUN npm ci
RUN npm ci \
--registry="${NPM_REGISTRY}" \
--replace-registry-host=registry.npmjs.org
COPY . .
RUN npm run build

View File

@@ -1,5 +0,0 @@
import { PortfolioSkeleton } from '@/components/skeletons/PortfolioSkeleton';
export default function Loading() {
return <PortfolioSkeleton />;
}

View File

@@ -1,5 +0,0 @@
import { PortfolioSkeleton } from '@/components/skeletons/PortfolioSkeleton';
export default function Loading() {
return <PortfolioSkeleton />;
}

View File

@@ -1,4 +1,3 @@
import { Suspense } from 'react';
import { ScenicHeroJourney } from '@/components/ScenicHeroJourney';
import { ProjectsSection } from '@/components/ProjectsSection';
import { ExperienceSection } from '@/components/ExperienceSection';
@@ -7,7 +6,6 @@ import { EducationSection } from '@/components/EducationSection';
import { MilestonesSection } from '@/components/MilestonesSection';
import { FooterSection } from '@/components/FooterSection';
import { MotionController } from '@/components/MotionController';
import { SectionSkeleton } from '@/components/skeletons/PortfolioSkeleton';
import type { Locale } from '@/i18n/content';
export function PortfolioPage({ locale }: { locale: Locale }) {
@@ -15,12 +13,12 @@ export function PortfolioPage({ locale }: { locale: Locale }) {
<main className="desktop-canvas" data-locale={locale}>
<MotionController />
<ScenicHeroJourney locale={locale} />
<Suspense fallback={<SectionSkeleton section="projects" />}><ProjectsSection locale={locale} /></Suspense>
<Suspense fallback={<SectionSkeleton section="experience" />}><ExperienceSection locale={locale} /></Suspense>
<Suspense fallback={<SectionSkeleton section="skills" />}><SkillsSection locale={locale} /></Suspense>
<Suspense fallback={<SectionSkeleton section="education" />}><EducationSection locale={locale} /></Suspense>
<Suspense fallback={<SectionSkeleton section="milestones" />}><MilestonesSection locale={locale} /></Suspense>
<Suspense fallback={<SectionSkeleton section="footer" />}><FooterSection locale={locale} /></Suspense>
<ProjectsSection locale={locale} />
<ExperienceSection locale={locale} />
<SkillsSection locale={locale} />
<EducationSection locale={locale} />
<MilestonesSection locale={locale} />
<FooterSection locale={locale} />
</main>
);
}

View File

@@ -1,4 +1,4 @@
import { ImageWithSkeleton } from './ImageWithSkeleton';
import Image from 'next/image';
import { getEducation } from '@/data/education';
import { messagesByLocale, type Locale } from '@/i18n/content';
import styles from './EducationSection.module.css';
@@ -8,7 +8,7 @@ export const EducationSection = ({ locale }: { locale: Locale }) => {
const copy = messagesByLocale[locale].education;
return (
<section id="education" className={styles.educationSection} data-deferred-render data-looping-motion>
<section id="education" className={styles.educationSection}>
<div className="content-container">
<div className="section-header" data-reveal="soft">
<div className="section-title-wrap">
@@ -21,13 +21,12 @@ export const EducationSection = ({ locale }: { locale: Locale }) => {
<div className={styles.card} data-reveal-group>
<div className={styles.crestCol} data-reveal="image">
<div className={styles.crestContainer}>
<ImageWithSkeleton
<Image
src={education.logo.src}
alt={education.institution}
fill
sizes="(max-width: 650px) 12rem, 16.5rem"
className={styles.crestImage}
skeletonShape="rounded"
/>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { ImageWithSkeleton } from './ImageWithSkeleton';
import Image from 'next/image';
import { getExperiences } from '@/data/experience';
import { messagesByLocale, type Locale } from '@/i18n/content';
import styles from './ExperienceSection.module.css';
@@ -8,7 +8,7 @@ export const ExperienceSection = ({ locale }: { locale: Locale }) => {
const copy = messagesByLocale[locale].experience;
return (
<section id="experience" className={styles.experienceSection} data-deferred-render data-looping-motion>
<section id="experience" className={styles.experienceSection}>
<div className="content-container">
<div className="section-header" data-reveal="soft">
<div className="section-title-wrap">
@@ -31,6 +31,7 @@ export const ExperienceSection = ({ locale }: { locale: Locale }) => {
className={`${styles.nodeDot} ${
item.isCurrent ? styles.nodeCurrent : ''
}`}
data-looping-motion={item.isCurrent ? '' : undefined}
aria-hidden="true"
/>
{index < experiences.length - 1 && (
@@ -65,13 +66,12 @@ export const ExperienceSection = ({ locale }: { locale: Locale }) => {
<div className={styles.roleIdentityRow}>
<div className={styles.logoContainer}>
<ImageWithSkeleton
<Image
src={item.logo.src}
alt={copy.logoAlt(item.company)}
fill
sizes="72px"
className={styles.logoImage}
skeletonShape="rounded"
/>
</div>

View File

@@ -4,7 +4,7 @@ import styles from './FooterSection.module.css';
export const FooterSection = ({ locale }: { locale: Locale }) => {
return (
<footer id="footer" className={styles.footerSection} data-deferred-render data-looping-motion>
<footer id="footer" className={styles.footerSection}>
<IsometricFooter locale={locale} />
</footer>
);

View File

@@ -284,15 +284,6 @@
animation-delay: 200ms;
}
:global(html[data-site-loading='true']) .topBar,
:global(html[data-site-loading='true']) .avatarWrapper,
:global(html[data-site-loading='true']) .heroTitle,
:global(html[data-site-loading='true']) .heroSubtitle,
:global(html[data-site-loading='true']) .actionButtons {
opacity: 0;
animation: none;
}
@keyframes heroContentEnter {
from {
opacity: 0;

View File

@@ -1,7 +1,7 @@
import { BookOpenText, GraduationCap, MessageCircle } from 'lucide-react';
import Image from 'next/image';
import { messagesByLocale, type Locale } from '@/i18n/content';
import { HeroControls } from './HeroControls';
import { ImageWithSkeleton } from './ImageWithSkeleton';
import styles from './HeroSection.module.css';
export const HeroSection = ({ locale }: { locale: Locale }) => {
@@ -14,13 +14,12 @@ export const HeroSection = ({ locale }: { locale: Locale }) => {
<div className={styles.avatarWrapper}>
<div className={styles.avatarRingOuter}>
<div className={styles.avatarContainer}>
<ImageWithSkeleton
<Image
src="/images/avatar.png"
alt={copy.avatarAlt}
fill
sizes="192px"
preload
skeletonShape="circle"
className={styles.avatarImg}
/>
</div>

View File

@@ -1,73 +0,0 @@
.frame {
position: absolute;
inset: 0;
display: block;
overflow: hidden;
border-radius: inherit;
}
.intrinsicFrame {
position: relative;
inset: auto;
width: var(--image-width);
height: var(--image-height);
}
.skeleton,
.error {
position: absolute;
inset: 0;
background: var(--bg-subtle);
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 20%,
color-mix(in srgb, var(--bg-raised) 72%, transparent) 48%,
transparent 76%
);
transform: translateX(-100%);
animation: imageSkeletonShimmer 1.35s ease-in-out infinite;
}
.circle {
border-radius: 50%;
}
.rounded {
border-radius: inherit;
}
.image {
opacity: 0;
transition: opacity 220ms var(--motion-ease-out);
}
.loaded {
opacity: 1;
}
.error {
display: grid;
place-items: center;
color: var(--text-muted);
background:
linear-gradient(135deg, transparent 46%, var(--border-default) 47% 53%, transparent 54%),
var(--bg-subtle);
}
@keyframes imageSkeletonShimmer {
to {
transform: translateX(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton::after {
display: none;
}
}

View File

@@ -1,113 +0,0 @@
'use client';
import Image, { type ImageProps } from 'next/image';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import styles from './ImageWithSkeleton.module.css';
type SkeletonShape = 'circle' | 'rounded' | 'square';
const nearViewportCallbacks = new WeakMap<Element, () => void>();
let nearViewportObserver: IntersectionObserver | undefined;
function observeNearViewport(element: Element, callback: () => void) {
nearViewportCallbacks.set(element, callback);
nearViewportObserver ??= new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
nearViewportCallbacks.get(entry.target)?.();
nearViewportCallbacks.delete(entry.target);
nearViewportObserver?.unobserve(entry.target);
});
},
{ rootMargin: '300px 0px' },
);
nearViewportObserver.observe(element);
return () => {
nearViewportCallbacks.delete(element);
nearViewportObserver?.unobserve(element);
};
}
export interface ImageWithSkeletonProps extends Omit<ImageProps, 'onLoad' | 'onError'> {
skeletonShape?: SkeletonShape;
onLoad?: ImageProps['onLoad'];
onError?: ImageProps['onError'];
}
export function ImageWithSkeleton({
alt,
className,
fill,
height,
width,
loading,
preload = false,
skeletonShape = 'rounded',
onLoad,
onError,
...props
}: ImageWithSkeletonProps) {
const frameRef = useRef<HTMLSpanElement>(null);
const eager = preload || loading === 'eager';
const [shouldRender, setShouldRender] = useState(eager);
const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>('loading');
const intrinsicStyle = !fill
? ({
'--image-width': `${Number(width)}px`,
'--image-height': `${Number(height)}px`,
} as CSSProperties)
: undefined;
useEffect(() => {
const frame = frameRef.current;
if (eager || shouldRender || !frame) return;
if (!('IntersectionObserver' in window)) {
const timer = setTimeout(() => setShouldRender(true), 0);
return () => clearTimeout(timer);
}
return observeNearViewport(frame, () => setShouldRender(true));
}, [eager, shouldRender]);
return (
<span
ref={frameRef}
className={`${styles.frame} ${!fill ? styles.intrinsicFrame : ''}`}
style={intrinsicStyle}
aria-busy={status === 'loading'}
data-image-state={status}
>
{status === 'loading' && (
<span
className={`${styles.skeleton} ${styles[skeletonShape]}`}
aria-hidden="true"
/>
)}
{status === 'error' && (
<span
className={`${styles.error} ${styles[skeletonShape]}`}
role={alt ? 'img' : undefined}
aria-label={alt || undefined}
aria-hidden={alt ? undefined : true}
/>
)}
{shouldRender && <Image
{...props}
alt={alt}
fill={fill}
height={height}
width={width}
loading={loading}
preload={preload}
className={`${styles.image} ${status === 'loaded' ? styles.loaded : ''} ${className ?? ''}`}
onLoad={(event) => {
setStatus('loaded');
onLoad?.(event);
}}
onError={(event) => {
setStatus('error');
onError?.(event);
}}
/>}
</span>
);
}

View File

@@ -36,7 +36,6 @@
color: var(--text-primary);
margin: 0 0 clamp(16px, 2.5vw, 24px) 0;
word-break: normal;
animation: footerFadeIn 0.7s ease-out forwards;
}
:global(html[lang='fa']) .headerTitle {
@@ -50,7 +49,6 @@
margin: 0 0 16px 0;
font-weight: 500;
letter-spacing: 0.02em;
animation: footerFadeIn 0.7s 0.15s ease-out both;
}
/* Email Pill Button */
@@ -58,7 +56,6 @@
display: flex;
justify-content: center;
width: 100%;
animation: emailScaleIn 0.6s 0.25s ease-out both;
}
.emailPill {
@@ -313,31 +310,6 @@
opacity: 1;
}
/* --------------------------------------------------------------------------
Animations
-------------------------------------------------------------------------- */
@keyframes footerFadeIn {
from {
opacity: 0;
transform: translateY(-16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes emailScaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* --------------------------------------------------------------------------
Responsive Media Queries
-------------------------------------------------------------------------- */

View File

@@ -52,16 +52,16 @@ export default function IsometricFooter({ locale }: { locale: Locale }) {
<FooterInteraction className={styles.footerContainer}>
{/* Top Header & Contact Section */}
<header className={styles.footerHeader}>
<h2 className={styles.headerTitle}>
<h2 className={styles.headerTitle} data-reveal="soft">
{copy.title}
</h2>
<p className={styles.headerSubtitle}>
<p className={styles.headerSubtitle} data-reveal="soft">
{copy.subtitle}
</p>
{/* Email Pill Button */}
<div className={styles.emailWrapper}>
<div className={styles.emailWrapper} data-reveal="scale">
<a
href="mailto:amirhosseinkhalili901@gmail.com"
className={styles.emailPill}

View File

@@ -1,4 +1,4 @@
import { ImageWithSkeleton } from './ImageWithSkeleton';
import Image from 'next/image';
import { ExternalLink } from 'lucide-react';
import { getMilestones } from '@/data/milestones';
import { messagesByLocale, type Locale } from '@/i18n/content';
@@ -10,7 +10,7 @@ export const MilestonesSection = ({ locale }: { locale: Locale }) => {
const copy = messagesByLocale[locale].milestones;
return (
<section id="milestones" className={styles.milestonesSection} data-deferred-render data-looping-motion>
<section id="milestones" className={styles.milestonesSection}>
<div className="content-container">
{/* Section Header */}
<div className="section-header" data-reveal="soft">
@@ -27,13 +27,12 @@ export const MilestonesSection = ({ locale }: { locale: Locale }) => {
<div key={item.id} className={styles.certCard}>
{/* Thumbnail */}
<div className={styles.thumbnailContainer} data-reveal="image">
<ImageWithSkeleton
<Image
src={item.image.src}
alt={item.title}
fill
sizes="(max-width: 650px) calc(100vw - 3rem), (max-width: 1024px) 44vw, 520px"
className={styles.certImage}
skeletonShape="rounded"
/>
</div>

View File

@@ -60,22 +60,40 @@ export const MotionController = () => {
const reduceMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
if (reduceMotion) {
return () => {
cancelAnimationFrame(anchorFrame);
anchorObserver?.disconnect();
window.removeEventListener('hashchange', queueAnchorRestore);
};
}
const elements = Array.from(
document.querySelectorAll<HTMLElement>(REVEAL_SELECTOR),
);
if (reduceMotion) {
elements.forEach((element) => {
element.dataset.visible = 'true';
});
document.documentElement.dataset.motionReady = 'true';
return () => {
cancelAnimationFrame(anchorFrame);
anchorObserver?.disconnect();
window.removeEventListener('hashchange', queueAnchorRestore);
delete document.documentElement.dataset.motionReady;
};
}
const initialRevealBoundary = window.innerHeight * 0.92;
elements.forEach((element) => {
const bounds = element.getBoundingClientRect();
if (bounds.top <= initialRevealBoundary && bounds.bottom >= 0) {
element.dataset.visible = 'true';
}
});
document.documentElement.dataset.motionReady = 'true';
const revealObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const element = entry.target as HTMLElement;
element.dataset.visible = 'true';
const siblingIndex = element.parentElement
? Array.from(element.parentElement.children).indexOf(element)
: 0;
@@ -95,7 +113,9 @@ export const MotionController = () => {
},
{ rootMargin: '0px 0px -8% 0px', threshold: 0.12 },
);
elements.forEach((element) => revealObserver.observe(element));
elements.forEach((element) => {
if (element.dataset.visible !== 'true') revealObserver.observe(element);
});
const loopingScopes = Array.from(
document.querySelectorAll<HTMLElement>('[data-looping-motion]'),
@@ -134,6 +154,7 @@ export const MotionController = () => {
revealObserver.disconnect();
loopingObserver.disconnect();
document.removeEventListener('visibilitychange', onVisibilityChange);
delete document.documentElement.dataset.motionReady;
};
}, []);

View File

@@ -1,9 +1,9 @@
'use client';
import { useState, type FC } from 'react';
import Image from 'next/image';
import { useTheme } from '@/context/ThemeContext';
import type { PortfolioImage } from '@/types/media';
import { ImageWithSkeleton } from './ImageWithSkeleton';
interface ProjectThumbnailProps {
image: PortfolioImage;
@@ -20,7 +20,7 @@ export const ProjectThumbnail: FC<ProjectThumbnailProps> = ({ image, title, clas
const useDarkImage = theme === 'dark' && unavailableDarkImage !== darkImage;
return (
<ImageWithSkeleton
<Image
key={useDarkImage ? darkImage : image.src}
src={useDarkImage ? darkImage : image.src}
alt={title}
@@ -29,7 +29,7 @@ export const ProjectThumbnail: FC<ProjectThumbnailProps> = ({ image, title, clas
quality={75}
draggable={false}
className={className}
skeletonShape="rounded"
placeholder={!useDarkImage && image.blurDataURL ? 'blur' : 'empty'}
blurDataURL={!useDarkImage ? image.blurDataURL : undefined}
onError={() => {
if (useDarkImage) setUnavailableDarkImage(darkImage);

View File

@@ -82,10 +82,12 @@
cursor: grab;
scrollbar-width: none;
overscroll-behavior-inline: contain;
touch-action: pan-y;
scroll-snap-type: x proximity;
touch-action: pan-x pan-y pinch-zoom;
user-select: none;
container-type: inline-size;
container-name: carousel;
-webkit-overflow-scrolling: touch;
}
.carouselViewport::-webkit-scrollbar {
@@ -346,7 +348,6 @@
}
.carouselViewport {
scroll-snap-type: x mandatory;
scroll-padding-inline: 0;
}
@@ -426,8 +427,6 @@
margin-inline: 0;
padding-inline: 0;
scroll-padding-inline: 0;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
.carouselTrack {
@@ -446,7 +445,6 @@
min-height: 26.25rem;
box-sizing: border-box;
scroll-snap-align: start;
scroll-snap-stop: always;
}
.mediaContainer {

View File

@@ -8,6 +8,8 @@ import { ProjectThumbnail } from './ProjectThumbnail';
import styles from './ProjectsSection.module.css';
const MIN_SCROLLBAR_THUMB_PERCENT = 12.5;
const prefersReducedMotion = () =>
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const getLogicalScrollLeft = (viewport: HTMLDivElement, isRtl: boolean) =>
isRtl ? -viewport.scrollLeft : viewport.scrollLeft;
@@ -30,13 +32,8 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
active: false,
moved: false,
startX: 0,
startY: 0,
startLogicalScrollLeft: 0,
lastX: 0,
lastTime: 0,
velocity: 0,
pointerId: -1,
directionLocked: null as 'horizontal' | 'vertical' | null,
});
const scrollbarDragState = useRef({
active: false,
@@ -101,6 +98,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
return () => {
resizeObserver.disconnect();
window.cancelAnimationFrame(scrollFrameRef.current);
scrollFrameRef.current = 0;
};
}, [syncScrollState]);
@@ -120,11 +118,16 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
maxScroll,
),
);
setLogicalScrollLeft(viewport, nextScroll, isRtl, 'smooth');
setLogicalScrollLeft(
viewport,
nextScroll,
isRtl,
prefersReducedMotion() ? 'auto' : 'smooth',
);
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0 && event.pointerType === 'mouse') return;
if (event.pointerType !== 'mouse' || event.button !== 0) return;
const viewport = viewportRef.current;
if (!viewport) return;
@@ -133,14 +136,11 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
active: true,
moved: false,
startX: event.clientX,
startY: event.clientY,
startLogicalScrollLeft: getLogicalScrollLeft(viewport, isRtl),
lastX: event.clientX,
lastTime: performance.now(),
velocity: 0,
pointerId: event.pointerId,
directionLocked: null,
};
viewport.setPointerCapture(event.pointerId);
setIsDragging(true);
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
@@ -148,59 +148,23 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
if (!viewport || !dragState.current.active) return;
const deltaX = event.clientX - dragState.current.startX;
const deltaY = event.clientY - dragState.current.startY;
// Detect user intent: horizontal carousel swipe vs vertical page scroll
if (!dragState.current.directionLocked) {
if (Math.abs(deltaX) < 6 && Math.abs(deltaY) < 6) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) {
// User intends to scroll page vertically, release touch
dragState.current.directionLocked = 'vertical';
dragState.current.active = false;
return;
}
// User intends horizontal carousel drag
dragState.current.directionLocked = 'horizontal';
try {
viewport.setPointerCapture(event.pointerId);
} catch {
// Ignore if pointer capture unavailable
}
setIsDragging(true);
}
if (dragState.current.directionLocked === 'horizontal') {
const now = performance.now();
const dt = Math.max(now - dragState.current.lastTime, 1);
const dx = event.clientX - dragState.current.lastX;
dragState.current.velocity = dx / dt;
dragState.current.lastX = event.clientX;
dragState.current.lastTime = now;
if (Math.abs(deltaX) > 4) dragState.current.moved = true;
const dragDirection = isRtl ? 1 : -1;
setLogicalScrollLeft(
viewport,
dragState.current.startLogicalScrollLeft + deltaX * dragDirection,
isRtl,
);
}
if (Math.abs(deltaX) > 4) dragState.current.moved = true;
const dragDirection = isRtl ? 1 : -1;
setLogicalScrollLeft(
viewport,
dragState.current.startLogicalScrollLeft + deltaX * dragDirection,
isRtl,
);
event.preventDefault();
};
const stopDragging = (event: React.PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current;
if (!viewport || !dragState.current.active) {
if (!viewport || !dragState.current.active || dragState.current.pointerId !== event.pointerId) {
setIsDragging(false);
return;
}
const wasHorizontal = dragState.current.directionLocked === 'horizontal';
const moved = dragState.current.moved;
const velocity = dragState.current.velocity;
const clientX = event.clientX ?? dragState.current.lastX;
const totalDeltaX = clientX - dragState.current.startX;
const pointerId = dragState.current.pointerId;
dragState.current.active = false;
@@ -212,39 +176,6 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
}
}
setIsDragging(false);
if (!wasHorizontal || !moved) return;
// Calculate snap target with velocity fling
const track = viewport.firstElementChild as HTMLElement | null;
const card = track?.firstElementChild as HTMLElement | null;
if (!track || !card) return;
const cardWidth = card.getBoundingClientRect().width;
const gap = Number.parseFloat(window.getComputedStyle(track).columnGap) || 0;
const step = cardWidth + gap;
if (step <= 0) return;
const currentScroll = getLogicalScrollLeft(viewport, isRtl);
let targetIndex: number;
if (Math.abs(velocity) > 0.2 || Math.abs(totalDeltaX) > cardWidth * 0.18) {
const isForwardGesture = isRtl
? totalDeltaX > 0 || velocity > 0.2
: totalDeltaX < 0 || velocity < -0.2;
if (isForwardGesture) {
targetIndex = Math.ceil(currentScroll / step);
} else {
targetIndex = Math.floor(currentScroll / step);
}
} else {
targetIndex = Math.round(currentScroll / step);
}
const maxIndex = projects.length - 1;
targetIndex = Math.max(0, Math.min(targetIndex, maxIndex));
setLogicalScrollLeft(viewport, targetIndex * step, isRtl, 'smooth');
};
const handleClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -355,7 +286,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
viewport,
event.key === 'Home' ? 0 : viewport.scrollWidth - viewport.clientWidth,
isRtl,
'smooth',
prefersReducedMotion() ? 'auto' : 'smooth',
);
}
};
@@ -365,8 +296,6 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
id="projects"
className={styles.projectsSection}
aria-labelledby="projects-title"
data-deferred-render
data-looping-motion
>
<div className="content-container">
<div className={styles.headerRow} data-reveal="soft">
@@ -420,6 +349,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
onPointerMove={handlePointerMove}
onPointerUp={stopDragging}
onPointerCancel={stopDragging}
onLostPointerCapture={stopDragging}
onClickCapture={handleClickCapture}
onKeyDown={handleKeyDown}
tabIndex={0}

View File

@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { ImageWithSkeleton } from './ImageWithSkeleton';
import Image from 'next/image';
import { Code, BarChart2, Wand2 } from 'lucide-react';
import {
skillsByDiscipline,
@@ -28,7 +28,7 @@ export const SkillsSection = ({ locale }: { locale: Locale }) => {
};
return (
<section id="skills" className={styles.skillsSection} data-deferred-render data-looping-motion>
<section id="skills" className={styles.skillsSection}>
<div className="content-container">
{/* Section Header */}
<div className="section-header" data-reveal="soft">
@@ -90,13 +90,12 @@ export const SkillsSection = ({ locale }: { locale: Locale }) => {
{activeDiscipline.tools.map((tool) => (
<div key={tool.id} className={styles.toolTile}>
<div className={styles.toolIconContainer}>
<ImageWithSkeleton
<Image
src={tool.icon.src}
alt=""
fill
sizes="64px"
className={styles.toolIcon}
skeletonShape="rounded"
/>
</div>
<span className={`${styles.toolLabel} ltr-token`} lang="en" dir="ltr">

View File

@@ -269,7 +269,7 @@ const createAboutScene: SceneFactory = ({ pointer, theme, reduceMotion }) => {
};
};
export function AboutRiverScene({ onReady }: { onReady?: () => void }) {
export function AboutRiverScene() {
const { theme } = useTheme();
return <ThreeCanvas className={styles.aboutScene} createScene={createAboutScene} theme={theme} onReady={onReady} />;
return <ThreeCanvas className={styles.aboutScene} createScene={createAboutScene} theme={theme} />;
}

View File

@@ -10,7 +10,6 @@ const AboutRiverScene = lazy(() =>
export function DeferredAboutRiverScene() {
const hostRef = useRef<HTMLDivElement>(null);
const [shouldLoad, setShouldLoad] = useState(false);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const host = hostRef.current;
@@ -28,11 +27,10 @@ export function DeferredAboutRiverScene() {
}, [shouldLoad]);
return (
<div ref={hostRef} className={styles.aboutSceneBoundary} aria-busy={!isReady} data-looping-motion>
{!isReady && <span className={styles.sceneSkeleton} aria-hidden="true" />}
<div ref={hostRef} className={styles.aboutSceneBoundary}>
{shouldLoad && (
<Suspense fallback={null}>
<AboutRiverScene onReady={() => setIsReady(true)} />
<AboutRiverScene />
</Suspense>
)}
</div>

View File

@@ -51,22 +51,6 @@
overflow: hidden;
}
.sceneSkeleton {
position: absolute;
inset: 0;
background:
linear-gradient(100deg, transparent 24%, color-mix(in srgb, var(--bg-raised) 68%, transparent) 50%, transparent 76%),
var(--bg-page);
background-size: 200% 100%, auto;
animation: sceneSkeletonShimmer 1.5s ease-in-out infinite;
}
@keyframes sceneSkeletonShimmer {
to {
background-position: -200% 0, 0 0;
}
}
.aboutScene::after {
content: '';
position: absolute;
@@ -114,9 +98,3 @@
width: 100vw;
}
}
@media (prefers-reduced-motion: reduce) {
.sceneSkeleton {
animation: none;
}
}

View File

@@ -26,7 +26,6 @@ interface ThreeCanvasProps {
className: string;
createScene: SceneFactory;
theme: SceneTheme;
onReady?: () => void;
}
function disposeObject(object: THREE.Object3D) {
@@ -50,13 +49,8 @@ function disposeObject(object: THREE.Object3D) {
});
}
export function ThreeCanvas({ className, createScene, theme, onReady }: ThreeCanvasProps) {
export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps) {
const hostRef = useRef<HTMLDivElement>(null);
const onReadyRef = useRef(onReady);
useEffect(() => {
onReadyRef.current = onReady;
}, [onReady]);
useEffect(() => {
const host = hostRef.current;
@@ -70,7 +64,6 @@ export function ThreeCanvas({ className, createScene, theme, onReady }: ThreeCan
let frame = 0;
let visible = true;
let pageVisible = !document.hidden;
let hasRendered = false;
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
@@ -81,10 +74,6 @@ export function ThreeCanvas({ className, createScene, theme, onReady }: ThreeCan
const render = () => {
renderer.render(controller.scene, controller.camera);
if (!hasRendered) {
hasRendered = true;
onReadyRef.current?.();
}
};
const resize = () => {
const bounds = host.getBoundingClientRect();

View File

@@ -1,66 +0,0 @@
.surface {
position: relative;
overflow: hidden;
background: var(--bg-subtle);
}
.surface::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 20%, color-mix(in srgb, var(--bg-raised) 72%, transparent) 50%, transparent 80%);
transform: translateX(-100%);
animation: skeletonShimmer 1.35s ease-in-out infinite;
}
.text { height: 0.8rem; border-radius: var(--radius-pill); }
.circle { aspect-ratio: 1; border-radius: 50%; }
.media { aspect-ratio: 16 / 9; border-radius: var(--radius-md); }
.card { min-height: 8rem; border: 1px solid var(--border-default); border-radius: var(--radius-lg); }
.control { width: 6rem; height: var(--size-button-height); border-radius: var(--radius-pill); }
.scene { width: 100%; height: 24rem; border-radius: var(--radius-lg); }
.route { width: 100%; max-width: var(--layout-frame); margin: 0 auto; background: var(--bg-page); }
.hero { min-height: 42rem; display: grid; place-items: center; padding: 4rem var(--layout-page-gutter); }
.heroStack { width: min(34rem, 100%); display: grid; justify-items: center; gap: 1rem; }
.avatar { width: 10.5rem; }
.wideText { width: min(26rem, 82%); height: 2rem; }
.mediumText { width: min(21rem, 68%); }
.controls { display: flex; gap: 0.75rem; }
.about { min-height: 54rem; padding: 8rem var(--layout-page-gutter); }
.section { padding: 7rem var(--layout-page-gutter); }
.header { display: grid; gap: 0.7rem; width: min(28rem, 82%); margin-bottom: 2.5rem; }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.25rem; }
.stack { display: grid; gap: 1rem; }
.section[data-section='projects'] { min-height: 50rem; }
.section[data-section='experience'] { min-height: 130rem; }
.section[data-section='skills'] { min-height: 77rem; }
.section[data-section='education'] { min-height: 30rem; }
.section[data-section='milestones'] { min-height: 104rem; }
.section[data-section='footer'] { min-height: 38rem; }
@keyframes skeletonShimmer { to { transform: translateX(100%); } }
@media (max-width: 1024px) {
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.section[data-section='experience'] { min-height: 144rem; }
.section[data-section='skills'] { min-height: 87rem; }
.section[data-section='milestones'] { min-height: 114rem; }
}
@media (max-width: 650px) {
.hero { min-height: 40rem; }
.about { min-height: 60rem; padding-block: 6rem; }
.section { padding-block: 5rem; }
.grid { grid-template-columns: 1fr; }
.section[data-section='projects'] { min-height: 47rem; }
.section[data-section='experience'] { min-height: 236rem; }
.section[data-section='skills'] { min-height: 212rem; }
.section[data-section='education'] { min-height: 64rem; }
.section[data-section='milestones'] { min-height: 231rem; }
.section[data-section='footer'] { min-height: 30rem; }
}
@media (prefers-reduced-motion: reduce) {
.surface::after { display: none; }
}

View File

@@ -1,48 +0,0 @@
import type { CSSProperties } from 'react';
import styles from './PortfolioSkeleton.module.css';
type SkeletonVariant = 'text' | 'circle' | 'media' | 'card' | 'control' | 'scene';
type SectionName = 'projects' | 'experience' | 'skills' | 'education' | 'milestones' | 'footer';
export function Skeleton({ variant, style }: { variant: SkeletonVariant; style?: CSSProperties }) {
return <span className={`${styles.surface} ${styles[variant]}`} style={style} aria-hidden="true" />;
}
export function SectionSkeleton({ section }: { section: SectionName }) {
const cardCount = section === 'experience' ? 6 : section === 'milestones' ? 5 : section === 'footer' ? 1 : 3;
return (
<section className={styles.section} data-section={section} aria-busy="true">
<div className={styles.header} aria-hidden="true">
<Skeleton variant="text" style={{ width: '30%' }} />
<Skeleton variant="text" style={{ width: '75%', height: '2rem' }} />
</div>
<div className={section === 'experience' || section === 'milestones' ? styles.stack : styles.grid}>
{Array.from({ length: cardCount }, (_, index) => (
<Skeleton key={index} variant={section === 'projects' ? 'media' : 'card'} />
))}
</div>
</section>
);
}
export function PortfolioSkeleton() {
return (
<main className={styles.route} aria-busy="true">
<section className={styles.hero}>
<div className={styles.heroStack} aria-hidden="true">
<Skeleton variant="circle" style={{ width: '10.5rem' }} />
<Skeleton variant="text" style={{ width: '82%', height: '2rem' }} />
<Skeleton variant="text" style={{ width: '65%' }} />
<div className={styles.controls}><Skeleton variant="control" /><Skeleton variant="control" /><Skeleton variant="control" /></div>
</div>
</section>
<section className={styles.about}><Skeleton variant="scene" /></section>
<SectionSkeleton section="projects" />
<SectionSkeleton section="experience" />
<SectionSkeleton section="skills" />
<SectionSkeleton section="education" />
<SectionSkeleton section="milestones" />
<SectionSkeleton section="footer" />
</main>
);
}

View File

@@ -66,45 +66,6 @@ html[dir='rtl'] body {
position: relative;
}
[data-deferred-render] {
content-visibility: auto;
contain: layout style paint;
}
#projects { contain-intrinsic-size: auto 800px; }
#experience { contain-intrinsic-size: auto 2085px; }
#skills { contain-intrinsic-size: auto 1240px; }
#education { contain-intrinsic-size: auto 480px; }
#milestones { contain-intrinsic-size: auto 1670px; }
#footer { contain-intrinsic-size: auto 615px; }
@media (max-width: 1024px) {
#projects { contain-intrinsic-size: auto 750px; }
#experience { contain-intrinsic-size: auto 2310px; }
#skills { contain-intrinsic-size: auto 1400px; }
#education { contain-intrinsic-size: auto 575px; }
#milestones { contain-intrinsic-size: auto 1835px; }
#footer { contain-intrinsic-size: auto 575px; }
}
@media (max-width: 768px) {
#projects { contain-intrinsic-size: auto 730px; }
#experience { contain-intrinsic-size: auto 2790px; }
#skills { contain-intrinsic-size: auto 1880px; }
#education { contain-intrinsic-size: auto 1010px; }
#milestones { contain-intrinsic-size: auto 2340px; }
#footer { contain-intrinsic-size: auto 500px; }
}
@media (max-width: 650px) {
#projects { contain-intrinsic-size: auto 750px; }
#experience { contain-intrinsic-size: auto 3780px; }
#skills { contain-intrinsic-size: auto 3385px; }
#education { contain-intrinsic-size: auto 1010px; }
#milestones { contain-intrinsic-size: auto 3690px; }
#footer { contain-intrinsic-size: auto 475px; }
}
@media (max-width: 650px) {
.section-header {
margin-bottom: var(--space-6);

View File

@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import { access, readFile } from 'node:fs/promises';
import test from 'node:test';
const removedSkeletonFiles = [
'src/app/(en)/loading.tsx',
'src/app/(fa)/fa/loading.tsx',
'src/components/ImageWithSkeleton.tsx',
'src/components/ImageWithSkeleton.module.css',
'src/components/skeletons/PortfolioSkeleton.tsx',
'src/components/skeletons/PortfolioSkeleton.module.css',
];
test('portfolio rendering no longer uses skeleton loading layers', async () => {
await Promise.all(
removedSkeletonFiles.map(async (file) => {
await assert.rejects(access(file), `${file} should be removed`);
}),
);
const [page, scene, sceneStyles, sourceFiles] = await Promise.all([
readFile('src/app/PortfolioPage.tsx', 'utf8'),
readFile('src/components/scenes/DeferredAboutRiverScene.tsx', 'utf8'),
readFile('src/components/scenes/Scenes.module.css', 'utf8'),
Promise.all([
'src/components/HeroSection.tsx',
'src/components/ProjectThumbnail.tsx',
'src/components/ExperienceSection.tsx',
'src/components/SkillsSection.tsx',
'src/components/EducationSection.tsx',
'src/components/MilestonesSection.tsx',
].map((file) => readFile(file, 'utf8'))).then((files) => files.join('\n')),
]);
assert.doesNotMatch(page, /Suspense|Skeleton/);
assert.doesNotMatch(sourceFiles, /ImageWithSkeleton|skeletonShape/);
assert.doesNotMatch(scene, /isReady|onReady|sceneSkeleton|aria-busy/);
assert.doesNotMatch(sceneStyles, /sceneSkeleton|sceneSkeletonShimmer/);
});
test('deferred section sizing cannot replace real layout while scrolling', async () => {
const [globals, sections] = await Promise.all([
readFile('src/styles/globals.css', 'utf8'),
Promise.all([
'src/components/ProjectsSection.tsx',
'src/components/ExperienceSection.tsx',
'src/components/SkillsSection.tsx',
'src/components/EducationSection.tsx',
'src/components/MilestonesSection.tsx',
'src/components/FooterSection.tsx',
].map((file) => readFile(file, 'utf8'))).then((files) => files.join('\n')),
]);
assert.doesNotMatch(globals, /content-visibility|contain-intrinsic-size/);
assert.doesNotMatch(sections, /data-deferred-render/);
});
test('project carousel uses native momentum with soft proximity snapping', async () => {
const [component, styles] = await Promise.all([
readFile('src/components/ProjectsSection.tsx', 'utf8'),
readFile('src/components/ProjectsSection.module.css', 'utf8'),
]);
assert.match(styles, /scroll-snap-type:\s*x proximity/);
assert.match(styles, /touch-action:\s*pan-x pan-y/);
assert.doesNotMatch(styles, /scroll-snap-type:\s*x mandatory/);
assert.doesNotMatch(styles, /scroll-snap-stop:\s*always/);
assert.match(component, /event\.pointerType !== 'mouse'/);
assert.match(
component,
/cancelAnimationFrame\(scrollFrameRef\.current\);\s*scrollFrameRef\.current = 0;/,
);
assert.doesNotMatch(component, /targetIndex|velocity fling|Math\.(?:ceil|floor|round)\(currentScroll \/ step\)/);
});
test('motion state is initialized for dependent section animations', async () => {
const [controller, footer] = await Promise.all([
readFile('src/components/MotionController.tsx', 'utf8'),
readFile('src/components/IsometricFooter/IsometricFooter.tsx', 'utf8'),
]);
assert.match(controller, /document\.documentElement\.dataset\.motionReady = 'true'/);
assert.match(controller, /element\.dataset\.visible = 'true'/);
assert.match(footer, /className=\{styles\.headerTitle\} data-reveal="soft"/);
assert.match(footer, /className=\{styles\.headerSubtitle\} data-reveal="soft"/);
assert.match(footer, /className=\{styles\.emailWrapper\} data-reveal="scale"/);
});