Compare commits

...

6 Commits

Author SHA1 Message Date
Ilia Mashkov 92e4a01641 refactor: group experience/ui components into subdirectories 2026-05-13 09:40:09 +03:00
Ilia Mashkov 9cf8caaead refactor: group project/ui components into subdirectories 2026-05-13 09:40:00 +03:00
Ilia Mashkov e518fc46a9 feat: section body animation with blur-out, delayed enter, and animation tokens
Add animation tokens to :root (--ease-spring, --ease-decelerate,
--ease-default, --duration-fast/normal/slow/spring). Apply spring easing
to section title enter. Add separate section-body transition: fast
blur-out exit (100ms), clean slide-in enter (350ms) delayed by 200ms so
content appears after the title animation completes.
2026-05-13 09:39:47 +03:00
Ilia Mashkov 481dda3c95 fix: resolve inactive section title hover opacity conflict
hover:opacity-60 on Link and opacity-30/group-hover:opacity-50 on h2
were multiplying (0.6 × 0.5 = 0.30 = base), making hover invisible.
Removed opacity from Link, consolidated to h2 only: opacity-30 base,
group-hover:opacity-60 on hover.
2026-05-13 09:39:08 +03:00
Ilia Mashkov d28343e22c feat: section open/close animations via ViewTransition and @starting-style
Enable experimental.viewTransition in Next.js config. Wrap active section
in ViewTransitionWrapper so the browser cross-fades between sections on
navigation. Replace animate-fadeIn keyframe with @starting-style + CSS
transition for the initial render enter animation.
2026-05-12 16:10:50 +03:00
Ilia Mashkov 7cba3053f4 feat: ViewTransitionWrapper shared component with stable react-dom fallback
Wraps children in React's ViewTransition (canary API) when available,
falling back to Fragment in environments where ViewTransition is undefined
(test env, stable react-dom). Add react/canary to tsconfig types to
expose the ViewTransition component type.
2026-05-12 16:10:37 +03:00
25 changed files with 180 additions and 27 deletions
+3
View File
@@ -8,6 +8,9 @@ const isExport = process.env.STATIC_EXPORT === 'true';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
...(isExport ? { output: 'export' } : {}), ...(isExport ? { output: 'export' } : {}),
images: { unoptimized: true }, images: { unoptimized: true },
experimental: {
viewTransition: true,
},
}; };
export default nextConfig; export default nextConfig;
@@ -56,9 +56,9 @@ describe('SectionAccordion', () => {
expect(screen.queryByRole('link')).not.toBeInTheDocument(); expect(screen.queryByRole('link')).not.toBeInTheDocument();
}); });
it('content wrapper has animate-fadeIn class', () => { it('content wrapper has section-content class', () => {
const { container } = render(<SectionAccordion {...activeProps} />); const { container } = render(<SectionAccordion {...activeProps} />);
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument(); expect(container.querySelector('.section-content')).toBeInTheDocument();
}); });
}); });
}); });
@@ -1,5 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { ViewTransitionWrapper } from '$shared/ui';
interface SectionAccordionProps { interface SectionAccordionProps {
/** /**
@@ -36,23 +37,24 @@ export function SectionAccordion({ number, title, id, isActive, href, children }
<section id={id} className="scroll-mt-8"> <section id={id} className="scroll-mt-8">
{isActive ? ( {isActive ? (
<div className="mb-12"> <div className="mb-12">
<div className="mb-16"> <ViewTransitionWrapper name="section-content">
<h1 <div className="mb-16">
className="font-heading font-black text-5xl leading-[1.2] mb-0" <h1
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }} className="font-heading font-black text-5xl leading-[1.2] mb-0"
> style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
{number}. {title} >
</h1> {number}. {title}
</div> </h1>
<div className="animate-fadeIn">{children}</div> </div>
</ViewTransitionWrapper>
<ViewTransitionWrapper name="section-body">
<div className="section-content">{children}</div>
</ViewTransitionWrapper>
</div> </div>
) : ( ) : (
<Link <Link href={href} className="block w-full text-left mb-3 py-3 group border-b-0 hover:border-b-0">
href={href}
className="block w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group border-b-0 hover:border-b-0"
>
<h2 <h2
className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-50 transition-opacity" className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-60 transition-opacity duration-200"
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }} style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
> >
{number}. {title} {number}. {title}
+1 -1
View File
@@ -1 +1 @@
export { ExperienceCard } from './ui/ExperienceCard'; export { ExperienceCard } from './ui';
+1
View File
@@ -0,0 +1 @@
export { ExperienceCard } from './ExperienceCard/ExperienceCard';
+1 -3
View File
@@ -1,3 +1 @@
export { DetailedProjectCard } from './ui/DetailedProjectCard'; export { DetailedProjectCard, ProjectCard, ProjectMetadata } from './ui';
export { ProjectCard } from './ui/ProjectCard';
export { ProjectMetadata } from './ui/ProjectMetadata';
@@ -1,6 +1,6 @@
import Image from 'next/image'; import Image from 'next/image';
import { Card } from '$shared/ui'; import { Card } from '$shared/ui';
import { ProjectMetadata } from './ProjectMetadata'; import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata';
type Props = { type Props = {
/** /**
+3
View File
@@ -0,0 +1,3 @@
export { DetailedProjectCard } from './DetailedProjectCard/DetailedProjectCard';
export { ProjectCard } from './ProjectCard/ProjectCard';
export { ProjectMetadata } from './ProjectMetadata/ProjectMetadata';
+90 -5
View File
@@ -75,6 +75,15 @@
/* === GRID === */ /* === GRID === */
--grid-gap: var(--space-3); --grid-gap: var(--space-3);
/* === ANIMATION === */
--ease-default: ease;
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-decelerate: cubic-bezier(0.25, 0, 0, 1);
--duration-fast: 100ms;
--duration-normal: 200ms;
--duration-slow: 350ms;
--duration-spring: 380ms;
} }
@theme inline { @theme inline {
@@ -230,17 +239,93 @@
border-right: var(--border-width) solid var(--blue); border-right: var(--border-width) solid var(--blue);
} }
/* Animations */ /* Section content enter animation (initial render, no navigation) */
@keyframes fadeIn { .section-content {
opacity: 1;
transform: translateY(0);
transition:
opacity var(--duration-slow) var(--ease-default),
transform var(--duration-slow) var(--ease-default);
}
@starting-style {
.section-content {
opacity: 0;
transform: translateY(12px);
}
}
/* Cross-section view transition (navigation between sections) */
::view-transition-old(section-content) {
animation-name: section-fade-out;
animation-duration: var(--duration-normal);
animation-timing-function: var(--ease-default);
animation-fill-mode: both;
}
::view-transition-new(section-content) {
animation-name: section-fade-in;
animation-duration: var(--duration-spring);
animation-timing-function: var(--ease-spring);
animation-fill-mode: both;
}
@keyframes section-fade-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-8px);
}
}
@keyframes section-fade-in {
from { from {
opacity: 0; opacity: 0;
transform: translateY(10px); transform: translateY(12px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
.animate-fadeIn {
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1); /* Section body: instant blur-out, clean slide-in */
::view-transition-old(section-body) {
animation-name: section-body-out;
animation-duration: var(--duration-fast);
animation-timing-function: var(--ease-default);
animation-fill-mode: both;
}
::view-transition-new(section-body) {
animation-name: section-body-in;
animation-duration: var(--duration-slow);
animation-delay: var(--duration-normal);
animation-timing-function: var(--ease-decelerate);
animation-fill-mode: both;
}
@keyframes section-body-out {
from {
opacity: 1;
filter: blur(0);
}
to {
opacity: 0;
filter: blur(3px);
}
}
@keyframes section-body-in {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
@@ -0,0 +1 @@
export { ViewTransitionWrapper } from './ui/ViewTransitionWrapper';
@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import { ViewTransitionWrapper } from './ViewTransitionWrapper';
describe('ViewTransitionWrapper', () => {
it('renders children', () => {
render(
<ViewTransitionWrapper name="test">
<p>Hello</p>
</ViewTransitionWrapper>,
);
expect(screen.getByText('Hello')).toBeInTheDocument();
});
it('renders multiple children', () => {
render(
<ViewTransitionWrapper name="test">
<p>First</p>
<p>Second</p>
</ViewTransitionWrapper>,
);
expect(screen.getByText('First')).toBeInTheDocument();
expect(screen.getByText('Second')).toBeInTheDocument();
});
it('does not add an extra wrapper DOM element', () => {
const { container } = render(
<ViewTransitionWrapper name="test">
<p>Content</p>
</ViewTransitionWrapper>,
);
expect(container.firstChild?.nodeName).toBe('P');
});
});
@@ -0,0 +1,26 @@
import { Fragment, type ReactNode, ViewTransition as VT } from 'react';
/**
* VT is undefined in stable react-dom (test env / non-experimental builds).
* Fall back to Fragment so children render and the name prop is silently ignored.
*/
const Transition = (VT ?? Fragment) as typeof VT;
type Props = {
/**
* Maps to the view-transition-name CSS property
*/
name: string;
/**
* Content to animate
*/
children: ReactNode;
};
/**
* Wraps children in React's ViewTransition when available,
* falling back to a Fragment in environments where ViewTransition is undefined.
*/
export function ViewTransitionWrapper({ name, children }: Props) {
return <Transition name={name}>{children}</Transition>;
}
+1
View File
@@ -10,3 +10,4 @@ export { RichText } from './RichText';
export type { ContainerSize, SectionBackground } from './Section'; export type { ContainerSize, SectionBackground } from './Section';
export { Container, Section } from './Section'; export { Container, Section } from './Section';
export { TechStackBrick, TechStackGrid } from './TechStack'; export { TechStackBrick, TechStackGrid } from './TechStack';
export { ViewTransitionWrapper } from './ViewTransitionWrapper';
+1 -1
View File
@@ -18,7 +18,7 @@
"name": "next" "name": "next"
} }
], ],
"types": ["vitest/globals"], "types": ["vitest/globals", "react/canary"],
"paths": { "paths": {
"@/*": ["./*"], "@/*": ["./*"],
"$shared/*": ["./src/shared/*"], "$shared/*": ["./src/shared/*"],