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 WORKDIR /app
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci \
--registry="${NPM_REGISTRY}" \
--replace-registry-host=registry.npmjs.org
COPY . . COPY . .
RUN npm run build 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 { ScenicHeroJourney } from '@/components/ScenicHeroJourney';
import { ProjectsSection } from '@/components/ProjectsSection'; import { ProjectsSection } from '@/components/ProjectsSection';
import { ExperienceSection } from '@/components/ExperienceSection'; import { ExperienceSection } from '@/components/ExperienceSection';
@@ -7,7 +6,6 @@ import { EducationSection } from '@/components/EducationSection';
import { MilestonesSection } from '@/components/MilestonesSection'; import { MilestonesSection } from '@/components/MilestonesSection';
import { FooterSection } from '@/components/FooterSection'; import { FooterSection } from '@/components/FooterSection';
import { MotionController } from '@/components/MotionController'; import { MotionController } from '@/components/MotionController';
import { SectionSkeleton } from '@/components/skeletons/PortfolioSkeleton';
import type { Locale } from '@/i18n/content'; import type { Locale } from '@/i18n/content';
export function PortfolioPage({ locale }: { locale: Locale }) { export function PortfolioPage({ locale }: { locale: Locale }) {
@@ -15,12 +13,12 @@ export function PortfolioPage({ locale }: { locale: Locale }) {
<main className="desktop-canvas" data-locale={locale}> <main className="desktop-canvas" data-locale={locale}>
<MotionController /> <MotionController />
<ScenicHeroJourney locale={locale} /> <ScenicHeroJourney locale={locale} />
<Suspense fallback={<SectionSkeleton section="projects" />}><ProjectsSection locale={locale} /></Suspense> <ProjectsSection locale={locale} />
<Suspense fallback={<SectionSkeleton section="experience" />}><ExperienceSection locale={locale} /></Suspense> <ExperienceSection locale={locale} />
<Suspense fallback={<SectionSkeleton section="skills" />}><SkillsSection locale={locale} /></Suspense> <SkillsSection locale={locale} />
<Suspense fallback={<SectionSkeleton section="education" />}><EducationSection locale={locale} /></Suspense> <EducationSection locale={locale} />
<Suspense fallback={<SectionSkeleton section="milestones" />}><MilestonesSection locale={locale} /></Suspense> <MilestonesSection locale={locale} />
<Suspense fallback={<SectionSkeleton section="footer" />}><FooterSection locale={locale} /></Suspense> <FooterSection locale={locale} />
</main> </main>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -284,15 +284,6 @@
animation-delay: 200ms; 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 { @keyframes heroContentEnter {
from { from {
opacity: 0; opacity: 0;

View File

@@ -1,7 +1,7 @@
import { BookOpenText, GraduationCap, MessageCircle } from 'lucide-react'; import { BookOpenText, GraduationCap, MessageCircle } from 'lucide-react';
import Image from 'next/image';
import { messagesByLocale, type Locale } from '@/i18n/content'; import { messagesByLocale, type Locale } from '@/i18n/content';
import { HeroControls } from './HeroControls'; import { HeroControls } from './HeroControls';
import { ImageWithSkeleton } from './ImageWithSkeleton';
import styles from './HeroSection.module.css'; import styles from './HeroSection.module.css';
export const HeroSection = ({ locale }: { locale: Locale }) => { export const HeroSection = ({ locale }: { locale: Locale }) => {
@@ -14,13 +14,12 @@ export const HeroSection = ({ locale }: { locale: Locale }) => {
<div className={styles.avatarWrapper}> <div className={styles.avatarWrapper}>
<div className={styles.avatarRingOuter}> <div className={styles.avatarRingOuter}>
<div className={styles.avatarContainer}> <div className={styles.avatarContainer}>
<ImageWithSkeleton <Image
src="/images/avatar.png" src="/images/avatar.png"
alt={copy.avatarAlt} alt={copy.avatarAlt}
fill fill
sizes="192px" sizes="192px"
preload preload
skeletonShape="circle"
className={styles.avatarImg} className={styles.avatarImg}
/> />
</div> </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); color: var(--text-primary);
margin: 0 0 clamp(16px, 2.5vw, 24px) 0; margin: 0 0 clamp(16px, 2.5vw, 24px) 0;
word-break: normal; word-break: normal;
animation: footerFadeIn 0.7s ease-out forwards;
} }
:global(html[lang='fa']) .headerTitle { :global(html[lang='fa']) .headerTitle {
@@ -50,7 +49,6 @@
margin: 0 0 16px 0; margin: 0 0 16px 0;
font-weight: 500; font-weight: 500;
letter-spacing: 0.02em; letter-spacing: 0.02em;
animation: footerFadeIn 0.7s 0.15s ease-out both;
} }
/* Email Pill Button */ /* Email Pill Button */
@@ -58,7 +56,6 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
animation: emailScaleIn 0.6s 0.25s ease-out both;
} }
.emailPill { .emailPill {
@@ -313,31 +310,6 @@
opacity: 1; 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 Responsive Media Queries
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,8 @@ import { ProjectThumbnail } from './ProjectThumbnail';
import styles from './ProjectsSection.module.css'; import styles from './ProjectsSection.module.css';
const MIN_SCROLLBAR_THUMB_PERCENT = 12.5; const MIN_SCROLLBAR_THUMB_PERCENT = 12.5;
const prefersReducedMotion = () =>
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const getLogicalScrollLeft = (viewport: HTMLDivElement, isRtl: boolean) => const getLogicalScrollLeft = (viewport: HTMLDivElement, isRtl: boolean) =>
isRtl ? -viewport.scrollLeft : viewport.scrollLeft; isRtl ? -viewport.scrollLeft : viewport.scrollLeft;
@@ -30,13 +32,8 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
active: false, active: false,
moved: false, moved: false,
startX: 0, startX: 0,
startY: 0,
startLogicalScrollLeft: 0, startLogicalScrollLeft: 0,
lastX: 0,
lastTime: 0,
velocity: 0,
pointerId: -1, pointerId: -1,
directionLocked: null as 'horizontal' | 'vertical' | null,
}); });
const scrollbarDragState = useRef({ const scrollbarDragState = useRef({
active: false, active: false,
@@ -101,6 +98,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
return () => { return () => {
resizeObserver.disconnect(); resizeObserver.disconnect();
window.cancelAnimationFrame(scrollFrameRef.current); window.cancelAnimationFrame(scrollFrameRef.current);
scrollFrameRef.current = 0;
}; };
}, [syncScrollState]); }, [syncScrollState]);
@@ -120,11 +118,16 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
maxScroll, maxScroll,
), ),
); );
setLogicalScrollLeft(viewport, nextScroll, isRtl, 'smooth'); setLogicalScrollLeft(
viewport,
nextScroll,
isRtl,
prefersReducedMotion() ? 'auto' : 'smooth',
);
}; };
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => { 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; const viewport = viewportRef.current;
if (!viewport) return; if (!viewport) return;
@@ -133,14 +136,11 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
active: true, active: true,
moved: false, moved: false,
startX: event.clientX, startX: event.clientX,
startY: event.clientY,
startLogicalScrollLeft: getLogicalScrollLeft(viewport, isRtl), startLogicalScrollLeft: getLogicalScrollLeft(viewport, isRtl),
lastX: event.clientX,
lastTime: performance.now(),
velocity: 0,
pointerId: event.pointerId, pointerId: event.pointerId,
directionLocked: null,
}; };
viewport.setPointerCapture(event.pointerId);
setIsDragging(true);
}; };
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => { const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
@@ -148,59 +148,23 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
if (!viewport || !dragState.current.active) return; if (!viewport || !dragState.current.active) return;
const deltaX = event.clientX - dragState.current.startX; const deltaX = event.clientX - dragState.current.startX;
const deltaY = event.clientY - dragState.current.startY; if (Math.abs(deltaX) > 4) dragState.current.moved = true;
const dragDirection = isRtl ? 1 : -1;
// Detect user intent: horizontal carousel swipe vs vertical page scroll setLogicalScrollLeft(
if (!dragState.current.directionLocked) { viewport,
if (Math.abs(deltaX) < 6 && Math.abs(deltaY) < 6) return; dragState.current.startLogicalScrollLeft + deltaX * dragDirection,
isRtl,
if (Math.abs(deltaY) > Math.abs(deltaX)) { );
// User intends to scroll page vertically, release touch event.preventDefault();
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,
);
}
}; };
const stopDragging = (event: React.PointerEvent<HTMLDivElement>) => { const stopDragging = (event: React.PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current; const viewport = viewportRef.current;
if (!viewport || !dragState.current.active) { if (!viewport || !dragState.current.active || dragState.current.pointerId !== event.pointerId) {
setIsDragging(false); setIsDragging(false);
return; 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; const pointerId = dragState.current.pointerId;
dragState.current.active = false; dragState.current.active = false;
@@ -212,39 +176,6 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
} }
} }
setIsDragging(false); 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>) => { const handleClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -355,7 +286,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
viewport, viewport,
event.key === 'Home' ? 0 : viewport.scrollWidth - viewport.clientWidth, event.key === 'Home' ? 0 : viewport.scrollWidth - viewport.clientWidth,
isRtl, isRtl,
'smooth', prefersReducedMotion() ? 'auto' : 'smooth',
); );
} }
}; };
@@ -365,8 +296,6 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
id="projects" id="projects"
className={styles.projectsSection} className={styles.projectsSection}
aria-labelledby="projects-title" aria-labelledby="projects-title"
data-deferred-render
data-looping-motion
> >
<div className="content-container"> <div className="content-container">
<div className={styles.headerRow} data-reveal="soft"> <div className={styles.headerRow} data-reveal="soft">
@@ -420,6 +349,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={stopDragging} onPointerUp={stopDragging}
onPointerCancel={stopDragging} onPointerCancel={stopDragging}
onLostPointerCapture={stopDragging}
onClickCapture={handleClickCapture} onClickCapture={handleClickCapture}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
tabIndex={0} tabIndex={0}

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { ImageWithSkeleton } from './ImageWithSkeleton'; import Image from 'next/image';
import { Code, BarChart2, Wand2 } from 'lucide-react'; import { Code, BarChart2, Wand2 } from 'lucide-react';
import { import {
skillsByDiscipline, skillsByDiscipline,
@@ -28,7 +28,7 @@ export const SkillsSection = ({ locale }: { locale: Locale }) => {
}; };
return ( return (
<section id="skills" className={styles.skillsSection} data-deferred-render data-looping-motion> <section id="skills" className={styles.skillsSection}>
<div className="content-container"> <div className="content-container">
{/* Section Header */} {/* Section Header */}
<div className="section-header" data-reveal="soft"> <div className="section-header" data-reveal="soft">
@@ -90,13 +90,12 @@ export const SkillsSection = ({ locale }: { locale: Locale }) => {
{activeDiscipline.tools.map((tool) => ( {activeDiscipline.tools.map((tool) => (
<div key={tool.id} className={styles.toolTile}> <div key={tool.id} className={styles.toolTile}>
<div className={styles.toolIconContainer}> <div className={styles.toolIconContainer}>
<ImageWithSkeleton <Image
src={tool.icon.src} src={tool.icon.src}
alt="" alt=""
fill fill
sizes="64px" sizes="64px"
className={styles.toolIcon} className={styles.toolIcon}
skeletonShape="rounded"
/> />
</div> </div>
<span className={`${styles.toolLabel} ltr-token`} lang="en" dir="ltr"> <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(); 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() { export function DeferredAboutRiverScene() {
const hostRef = useRef<HTMLDivElement>(null); const hostRef = useRef<HTMLDivElement>(null);
const [shouldLoad, setShouldLoad] = useState(false); const [shouldLoad, setShouldLoad] = useState(false);
const [isReady, setIsReady] = useState(false);
useEffect(() => { useEffect(() => {
const host = hostRef.current; const host = hostRef.current;
@@ -28,11 +27,10 @@ export function DeferredAboutRiverScene() {
}, [shouldLoad]); }, [shouldLoad]);
return ( return (
<div ref={hostRef} className={styles.aboutSceneBoundary} aria-busy={!isReady} data-looping-motion> <div ref={hostRef} className={styles.aboutSceneBoundary}>
{!isReady && <span className={styles.sceneSkeleton} aria-hidden="true" />}
{shouldLoad && ( {shouldLoad && (
<Suspense fallback={null}> <Suspense fallback={null}>
<AboutRiverScene onReady={() => setIsReady(true)} /> <AboutRiverScene />
</Suspense> </Suspense>
)} )}
</div> </div>

View File

@@ -51,22 +51,6 @@
overflow: hidden; 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 { .aboutScene::after {
content: ''; content: '';
position: absolute; position: absolute;
@@ -114,9 +98,3 @@
width: 100vw; width: 100vw;
} }
} }
@media (prefers-reduced-motion: reduce) {
.sceneSkeleton {
animation: none;
}
}

View File

@@ -26,7 +26,6 @@ interface ThreeCanvasProps {
className: string; className: string;
createScene: SceneFactory; createScene: SceneFactory;
theme: SceneTheme; theme: SceneTheme;
onReady?: () => void;
} }
function disposeObject(object: THREE.Object3D) { 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 hostRef = useRef<HTMLDivElement>(null);
const onReadyRef = useRef(onReady);
useEffect(() => {
onReadyRef.current = onReady;
}, [onReady]);
useEffect(() => { useEffect(() => {
const host = hostRef.current; const host = hostRef.current;
@@ -70,7 +64,6 @@ export function ThreeCanvas({ className, createScene, theme, onReady }: ThreeCan
let frame = 0; let frame = 0;
let visible = true; let visible = true;
let pageVisible = !document.hidden; let pageVisible = !document.hidden;
let hasRendered = false;
renderer.setClearColor(0x000000, 0); renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.outputColorSpace = THREE.SRGBColorSpace;
@@ -81,10 +74,6 @@ export function ThreeCanvas({ className, createScene, theme, onReady }: ThreeCan
const render = () => { const render = () => {
renderer.render(controller.scene, controller.camera); renderer.render(controller.scene, controller.camera);
if (!hasRendered) {
hasRendered = true;
onReadyRef.current?.();
}
}; };
const resize = () => { const resize = () => {
const bounds = host.getBoundingClientRect(); 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; 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) { @media (max-width: 650px) {
.section-header { .section-header {
margin-bottom: var(--space-6); 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"/);
});