From 49cafe71617ed6d833f4c01cef3424bb756dbe26 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 21 May 2026 20:28:28 +0300 Subject: [PATCH 01/19] feat: ImageLightbox placeholder --- src/shared/ui/ImageLightbox/index.ts | 1 + .../ui/ImageLightbox/ui/ImageLightbox.tsx | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 src/shared/ui/ImageLightbox/index.ts create mode 100644 src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx diff --git a/src/shared/ui/ImageLightbox/index.ts b/src/shared/ui/ImageLightbox/index.ts new file mode 100644 index 0000000..b43fbfb --- /dev/null +++ b/src/shared/ui/ImageLightbox/index.ts @@ -0,0 +1 @@ +export { ImageLightbox } from './ui/ImageLightbox'; diff --git a/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx b/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx new file mode 100644 index 0000000..1789e74 --- /dev/null +++ b/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx @@ -0,0 +1,23 @@ +'use client'; + +type Props = { + /** + * Image source URL + */ + src: string; + /** + * Image alt text, also used as the dialog accessible label + */ + alt: string; + /** + * CSS classes forwarded to the thumbnail button wrapper + */ + className?: string; +}; + +/** + * Clickable image thumbnail that opens a fullscreen brutalist dialog on click. + */ +export function ImageLightbox(_props: Props) { + return null; +} From c7ed458c8eed5f2515b255805470451b69a5a8ce Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 22 May 2026 10:14:53 +0300 Subject: [PATCH 02/19] test: ImageLightbox failing tests --- .../ImageLightbox/ui/ImageLightbox.test.tsx | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx diff --git a/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx b/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx new file mode 100644 index 0000000..07e1537 --- /dev/null +++ b/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { ImageLightbox } from './ImageLightbox'; + +// jsdom does not implement dialog methods — mock them +beforeAll(() => { + HTMLDialogElement.prototype.showModal = vi.fn(); + HTMLDialogElement.prototype.close = vi.fn(); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +const DEFAULT_PROPS = { src: '/project.jpg', alt: 'My Project' }; + +describe('ImageLightbox', () => { + describe('thumbnail', () => { + it('renders a thumbnail image', () => { + render(); + expect(screen.getByRole('img', { name: 'My Project' })).toBeInTheDocument(); + }); + + it('thumbnail button has cursor-zoom-in', () => { + render(); + const btn = screen.getByRole('button', { name: 'My Project' }); + expect(btn).toHaveClass('cursor-zoom-in'); + }); + + it('forwards className to the thumbnail button', () => { + render(); + expect(screen.getByRole('button', { name: 'My Project' })).toHaveClass('extra-class'); + }); + }); + + describe('dialog', () => { + it('clicking the thumbnail opens the dialog', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'My Project' })); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + }); + + it('clicking the close button closes the dialog', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /close/i })); + expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1); + }); + + it('clicking the backdrop (dialog element itself) closes the dialog', () => { + render(); + const dialog = document.querySelector('dialog')!; + fireEvent.click(dialog, { target: dialog }); + expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1); + }); + + it('clicking inside the dialog (not backdrop) does not close', () => { + render(); + const dialog = document.querySelector('dialog')!; + const inner = dialog.querySelector('div')!; + fireEvent.click(inner); + expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled(); + }); + + it('dialog has accessible label matching alt text', () => { + render(); + expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My Project'); + }); + }); +}); From bfb0b46a37146b15a1e2d69397070ace3eb79dce Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 22 May 2026 12:01:08 +0300 Subject: [PATCH 03/19] feat: implement ImageLightbox with tests --- .../ImageLightbox/ui/ImageLightbox.test.tsx | 9 +-- .../ui/ImageLightbox/ui/ImageLightbox.tsx | 67 ++++++++++++++++++- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx b/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx index 07e1537..3610199 100644 --- a/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx +++ b/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx @@ -41,21 +41,22 @@ describe('ImageLightbox', () => { it('clicking the close button closes the dialog', () => { render(); - fireEvent.click(screen.getByRole('button', { name: /close/i })); + fireEvent.click(screen.getByRole('button', { name: 'My Project' })); // open first + fireEvent.click(screen.getByRole('button', { name: /close/i, hidden: true })); expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1); }); it('clicking the backdrop (dialog element itself) closes the dialog', () => { render(); - const dialog = document.querySelector('dialog')!; + const dialog = document.querySelector('dialog') as HTMLDialogElement; fireEvent.click(dialog, { target: dialog }); expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1); }); it('clicking inside the dialog (not backdrop) does not close', () => { render(); - const dialog = document.querySelector('dialog')!; - const inner = dialog.querySelector('div')!; + const dialog = document.querySelector('dialog') as HTMLDialogElement; + const inner = dialog.querySelector('div') as HTMLDivElement; fireEvent.click(inner); expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled(); }); diff --git a/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx b/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx index 1789e74..ba4bff9 100644 --- a/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx +++ b/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx @@ -1,5 +1,9 @@ 'use client'; +import Image from 'next/image'; +import { useRef } from 'react'; +import { cn } from '$shared/lib'; + type Props = { /** * Image source URL @@ -18,6 +22,65 @@ type Props = { /** * Clickable image thumbnail that opens a fullscreen brutalist dialog on click. */ -export function ImageLightbox(_props: Props) { - return null; +export function ImageLightbox({ src, alt, className }: Props) { + const dialogRef = useRef(null); + + function open() { + dialogRef.current?.showModal(); + } + + function close() { + dialogRef.current?.close(); + } + + function handleBackdropClick(e: React.MouseEvent) { + if (e.target === e.currentTarget) { + close(); + } + } + + /** + * Keyboard equivalent of backdrop click — closes the dialog when the user + * activates the backdrop area (dialog element itself) via Enter or Space. + * ESC is handled natively by showModal(); this covers explicit backdrop activation. + */ + function handleBackdropKeyUp(e: React.KeyboardEvent) { + if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { + close(); + } + } + + return ( + <> + + + +
+ {/* aria-hidden: the dialog element itself carries the accessible label */} + {alt} +
+ +
+ + ); } From eb13328f9a6139b9403685b3919065c91faba336 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 22 May 2026 12:05:42 +0300 Subject: [PATCH 04/19] feat: add lightbox backdrop CSS and export ImageLightbox --- src/shared/styles/theme.css | 18 ++++++++++++++++++ src/shared/ui/index.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index fbd5fec..60c29d6 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -154,6 +154,7 @@ html { font-size: var(--font-size); + scroll-behavior: smooth; } body { @@ -417,8 +418,25 @@ /* Keep footer above sliding section-body during view transitions */ .footer-vt { view-transition-name: site-footer; + animation: footer-enter var(--duration-slow) var(--ease-spring) both; +} + +@keyframes footer-enter { + from { + opacity: 0; + transform: translateY(100%); + } + to { + opacity: 1; + transform: translateY(0); + } } ::view-transition-group(site-footer) { z-index: 10; } + +/* Lightbox dialog backdrop */ +dialog.lightbox::backdrop { + background-color: rgba(4, 28, 243, 0.25); +} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 401c379..1f2e162 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -4,7 +4,7 @@ export type { ButtonSize, ButtonVariant } from './Button'; export { Button } from './Button'; export type { CardBackground } from './Card'; export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card'; - +export { ImageLightbox } from './ImageLightbox'; export { InlineSvg } from './InlineSvg'; export { Input, Textarea } from './Input'; export type { LinkVariant } from './Link'; From eeb7d6b4a6f6003b62d0ab94767bf2b1b5b9e1bf Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 22 May 2026 12:18:00 +0300 Subject: [PATCH 05/19] feat: use ImageLightbox in ProjectCard --- src/entities/project/ui/ProjectCard/ProjectCard.test.tsx | 6 ++++++ src/entities/project/ui/ProjectCard/ProjectCard.tsx | 9 ++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/entities/project/ui/ProjectCard/ProjectCard.test.tsx b/src/entities/project/ui/ProjectCard/ProjectCard.test.tsx index 0d3cef8..9d3db95 100644 --- a/src/entities/project/ui/ProjectCard/ProjectCard.test.tsx +++ b/src/entities/project/ui/ProjectCard/ProjectCard.test.tsx @@ -124,5 +124,11 @@ describe('ProjectCard', () => { const imgWrapper = container.querySelector('img')?.parentElement; expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border'); }); + + it('image is wrapped in a lightbox button with cursor-zoom-in', () => { + render(); + const btn = screen.getByRole('button', { name: DEFAULT_PROPS.title }); + expect(btn).toHaveClass('cursor-zoom-in'); + }); }); }); diff --git a/src/entities/project/ui/ProjectCard/ProjectCard.tsx b/src/entities/project/ui/ProjectCard/ProjectCard.tsx index b1d7422..2af0474 100644 --- a/src/entities/project/ui/ProjectCard/ProjectCard.tsx +++ b/src/entities/project/ui/ProjectCard/ProjectCard.tsx @@ -1,6 +1,5 @@ -import Image from 'next/image'; import { cn } from '$shared/lib'; -import { Badge, Button, Card, CardSidebar, CardTitle, RichText } from '$shared/ui'; +import { Badge, Button, Card, CardSidebar, CardTitle, ImageLightbox, RichText } from '$shared/ui'; type Props = { /** @@ -58,11 +57,7 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl }: P >
{title} - {imageUrl && ( -
- {title} -
- )} + {imageUrl && }
From 7a06d42d206875f980697c010995f154c1c2860a Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 22 May 2026 12:46:47 +0300 Subject: [PATCH 06/19] feat: add icon components and update ImageLightbox with icons and Button --- src/shared/assets/icons/CloseIcon.tsx | 30 +++++++++++++++++++ src/shared/assets/icons/MagnifyIcon.tsx | 30 +++++++++++++++++++ src/shared/assets/icons/index.ts | 2 ++ .../ui/ImageLightbox.stories.tsx | 25 ++++++++++++++++ .../ui/ImageLightbox/ui/ImageLightbox.tsx | 24 +++++++++------ 5 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 src/shared/assets/icons/CloseIcon.tsx create mode 100644 src/shared/assets/icons/MagnifyIcon.tsx create mode 100644 src/shared/assets/icons/index.ts create mode 100644 src/shared/ui/ImageLightbox/ui/ImageLightbox.stories.tsx diff --git a/src/shared/assets/icons/CloseIcon.tsx b/src/shared/assets/icons/CloseIcon.tsx new file mode 100644 index 0000000..722f9e5 --- /dev/null +++ b/src/shared/assets/icons/CloseIcon.tsx @@ -0,0 +1,30 @@ +type Props = { + /** + * CSS classes on the svg element + */ + className?: string; +}; + +/** + * Close / X icon (Lucide). + */ +export function CloseIcon({ className }: Props) { + return ( + + + + + ); +} diff --git a/src/shared/assets/icons/MagnifyIcon.tsx b/src/shared/assets/icons/MagnifyIcon.tsx new file mode 100644 index 0000000..d6197cc --- /dev/null +++ b/src/shared/assets/icons/MagnifyIcon.tsx @@ -0,0 +1,30 @@ +type Props = { + /** + * CSS classes on the svg element + */ + className?: string; +}; + +/** + * Magnify / search icon (Lucide). + */ +export function MagnifyIcon({ className }: Props) { + return ( + + + + + ); +} diff --git a/src/shared/assets/icons/index.ts b/src/shared/assets/icons/index.ts new file mode 100644 index 0000000..21698bf --- /dev/null +++ b/src/shared/assets/icons/index.ts @@ -0,0 +1,2 @@ +export { CloseIcon } from './CloseIcon'; +export { MagnifyIcon } from './MagnifyIcon'; diff --git a/src/shared/ui/ImageLightbox/ui/ImageLightbox.stories.tsx b/src/shared/ui/ImageLightbox/ui/ImageLightbox.stories.tsx new file mode 100644 index 0000000..8da395c --- /dev/null +++ b/src/shared/ui/ImageLightbox/ui/ImageLightbox.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { ImageLightbox } from './ImageLightbox'; + +const meta: Meta = { + title: 'Shared/ImageLightbox', + component: ImageLightbox, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + src: 'https://picsum.photos/800/450', + alt: 'Sample project image', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx b/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx index ba4bff9..bc92aff 100644 --- a/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx +++ b/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx @@ -2,7 +2,9 @@ import Image from 'next/image'; import { useRef } from 'react'; +import { CloseIcon, MagnifyIcon } from '$shared/assets/icons'; import { cn } from '$shared/lib'; +import { Button } from '$shared/ui/Button'; type Props = { /** @@ -33,6 +35,11 @@ export function ImageLightbox({ src, alt, className }: Props) { dialogRef.current?.close(); } + /** + * Closes the dialog when the user clicks the backdrop area directly. + * Comparing target to currentTarget distinguishes a click on the + * element itself (the backdrop) from a click on its content children. + */ function handleBackdropClick(e: React.MouseEvent) { if (e.target === e.currentTarget) { close(); @@ -59,6 +66,10 @@ export function ImageLightbox({ src, alt, className }: Props) { className={cn('relative block w-full aspect-video overflow-hidden cursor-zoom-in', className)} > {alt} + {/* Magnify hint — pointer-events-none so clicks pass through to the button */} + + +
{/* aria-hidden: the dialog element itself carries the accessible label */} {alt}
- +
); From 43242c3bedbfa4e8493c78cfe478bd39fc576362 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 22 May 2026 13:30:28 +0300 Subject: [PATCH 07/19] refactor: swap Button ghost/outline semantics, clean up ImageLightbox thumbnail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ghost now means transparent bg (no fill); outline keeps cream bg with subtle border. Remove magnify icon overlay from ImageLightbox thumbnail — hover cursor-zoom-in is sufficient. Close button updated to variant="outline" for cream-on-blue contrast in the dialog. --- src/shared/styles/theme.css | 1 + src/shared/ui/Button/ui/Button.test.tsx | 4 ++-- src/shared/ui/Button/ui/Button.tsx | 4 ++-- .../ImageLightbox/ui/ImageLightbox.test.tsx | 2 +- .../ui/ImageLightbox/ui/ImageLightbox.tsx | 23 ++++++++++--------- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index 60c29d6..8ac264e 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -439,4 +439,5 @@ /* Lightbox dialog backdrop */ dialog.lightbox::backdrop { background-color: rgba(4, 28, 243, 0.25); + backdrop-filter: blur(4px); } diff --git a/src/shared/ui/Button/ui/Button.test.tsx b/src/shared/ui/Button/ui/Button.test.tsx index 388838f..78ee56b 100644 --- a/src/shared/ui/Button/ui/Button.test.tsx +++ b/src/shared/ui/Button/ui/Button.test.tsx @@ -24,11 +24,11 @@ describe('Button', () => { }); it('applies outline variant', () => { render(); - expect(screen.getByRole('button')).toHaveClass('bg-transparent'); + expect(screen.getByRole('button')).toHaveClass('bg-cream'); }); it('applies ghost variant', () => { render(); - expect(screen.getByRole('button')).toHaveClass('bg-cream'); + expect(screen.getByRole('button')).toHaveClass('bg-transparent'); }); }); describe('sizes', () => { diff --git a/src/shared/ui/Button/ui/Button.tsx b/src/shared/ui/Button/ui/Button.tsx index 2a11e58..c3a8103 100644 --- a/src/shared/ui/Button/ui/Button.tsx +++ b/src/shared/ui/Button/ui/Button.tsx @@ -46,9 +46,9 @@ const VARIANTS = { secondary: 'brutal-border bg-blue text-cream shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs', outline: - 'brutal-border bg-transparent text-blue shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs', - ghost: 'brutal-border border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream', + ghost: + 'brutal-border bg-transparent text-blue hover:-translate-x-0.5 hover:-translate-y-0.5 active:translate-x-0.5 active:translate-y-0.5', } as const satisfies Record; const SIZES = { diff --git a/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx b/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx index 3610199..9fb2261 100644 --- a/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx +++ b/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx @@ -56,7 +56,7 @@ describe('ImageLightbox', () => { it('clicking inside the dialog (not backdrop) does not close', () => { render(); const dialog = document.querySelector('dialog') as HTMLDialogElement; - const inner = dialog.querySelector('div') as HTMLDivElement; + const inner = dialog.querySelector('img') as HTMLImageElement; fireEvent.click(inner); expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled(); }); diff --git a/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx b/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx index bc92aff..73becfc 100644 --- a/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx +++ b/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx @@ -2,7 +2,7 @@ import Image from 'next/image'; import { useRef } from 'react'; -import { CloseIcon, MagnifyIcon } from '$shared/assets/icons'; +import { CloseIcon } from '$shared/assets/icons'; import { cn } from '$shared/lib'; import { Button } from '$shared/ui/Button'; @@ -66,10 +66,6 @@ export function ImageLightbox({ src, alt, className }: Props) { className={cn('relative block w-full aspect-video overflow-hidden cursor-zoom-in', className)} > {alt} - {/* Magnify hint — pointer-events-none so clicks pass through to the button */} - - - -
- {/* aria-hidden: the dialog element itself carries the accessible label */} - {alt} -
-
From 5b686ad87c77ca9c2c559d3c94de8deecde58093 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 22 May 2026 14:07:26 +0300 Subject: [PATCH 08/19] fix: SectionAccordion animation misbehave --- .../ui/SectionAccordion/SectionAccordion.test.tsx | 5 ----- .../Section/ui/SectionAccordion/SectionAccordion.tsx | 6 +++--- src/shared/styles/theme.css | 11 +++++++++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx b/src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx index a5714b4..6c5d182 100644 --- a/src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx +++ b/src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx @@ -55,10 +55,5 @@ describe('SectionAccordion', () => { render(); expect(screen.queryByRole('link')).not.toBeInTheDocument(); }); - - it('content wrapper has section-content class', () => { - const { container } = render(); - expect(container.querySelector('.section-content')).toBeInTheDocument(); - }); }); }); diff --git a/src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx b/src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx index 8acdfd3..c6bd2dd 100644 --- a/src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx +++ b/src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx @@ -39,13 +39,13 @@ export function SectionAccordion({ number, title, id, isActive, href, children }
{isActive ? (
- -
+ +

{heading}

-
{children}
+
{children}
) : ( diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index 8ac264e..9015690 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -341,14 +341,14 @@ } /* Cross-section view transition (navigation between sections) */ -::view-transition-old(section-content) { +::view-transition-old(section-title) { 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) { +::view-transition-new(section-title) { animation-name: section-fade-in; animation-duration: var(--duration-spring); animation-timing-function: var(--ease-spring); @@ -377,6 +377,13 @@ } } +/* Disable group geometry interpolation — OLD and NEW live at different scroll + * positions, so morphing the container drags the slide-in across the viewport. + * Let old/new each animate at their own positions instead. */ +::view-transition-group(section-body) { + animation: none; +} + /* Section body slide-in from right */ ::view-transition-old(section-body) { animation-name: section-body-out; From a31cf4deec5abeaa0475cb72f3445385a01c4862 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 22 May 2026 15:30:42 +0300 Subject: [PATCH 09/19] fix: opt Next.js out of smooth scroll for route transitions Adds data-scroll-behavior="smooth" to so Next disables the global scroll-behavior during programmatic route-change scrolls while keeping smooth behavior for user-driven anchor jumps. Silences the Next 15 warning. --- app/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/layout.tsx b/app/layout.tsx index 4ee2fdf..af12209 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -14,7 +14,7 @@ export const metadata: Metadata = { */ export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children}
From c4002ebb4fd4c10a4e1e4e2175789861c4c5c4f7 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 23 May 2026 09:50:29 +0300 Subject: [PATCH 10/19] chore: use node:path protocol in vitest config --- vitest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vitest.config.ts b/vitest.config.ts index 71a8ef2..78e2d82 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,5 @@ +import path from 'node:path'; import react from '@vitejs/plugin-react'; -import path from 'path'; import { defineConfig } from 'vitest/config'; export default defineConfig({ From 82933dedf884c1869b82ea783c63893596c65437 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 23 May 2026 09:50:42 +0300 Subject: [PATCH 11/19] feat(shared): add Modal component Native +showModal() wrapper with imperative open()/close() via ref, body scroll lock, and backdrop-click/keyboard-close behaviors. Exposes onCancel and onBackdropClose escape hatches for consumers that need to wrap the close path (e.g. in a view transition). --- src/shared/ui/Modal/index.ts | 1 + src/shared/ui/Modal/ui/Modal.stories.tsx | 56 +++++++++ src/shared/ui/Modal/ui/Modal.test.tsx | 145 +++++++++++++++++++++++ src/shared/ui/Modal/ui/Modal.tsx | 121 +++++++++++++++++++ src/shared/ui/index.ts | 1 + 5 files changed, 324 insertions(+) create mode 100644 src/shared/ui/Modal/index.ts create mode 100644 src/shared/ui/Modal/ui/Modal.stories.tsx create mode 100644 src/shared/ui/Modal/ui/Modal.test.tsx create mode 100644 src/shared/ui/Modal/ui/Modal.tsx diff --git a/src/shared/ui/Modal/index.ts b/src/shared/ui/Modal/index.ts new file mode 100644 index 0000000..506d976 --- /dev/null +++ b/src/shared/ui/Modal/index.ts @@ -0,0 +1 @@ +export { Modal, type ModalHandle } from './ui/Modal'; diff --git a/src/shared/ui/Modal/ui/Modal.stories.tsx b/src/shared/ui/Modal/ui/Modal.stories.tsx new file mode 100644 index 0000000..e5364c4 --- /dev/null +++ b/src/shared/ui/Modal/ui/Modal.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useRef } from 'react'; +import { Button } from '../../Button'; +import { Modal, type ModalHandle } from './Modal'; + +const meta: Meta = { + title: 'Shared/Modal', + component: Modal, +}; + +export default meta; + +type Story = StoryObj; + +/** + * Imperative trigger — Storybook can't drive a ref-based API directly, so each + * story renders its own trigger button that calls `modalRef.current?.open()`. + */ +function TriggerWrapper({ className, children }: { className?: string; children: React.ReactNode }) { + const modalRef = useRef(null); + return ( +
+ + +
+ {children} +
+ +
+
+
+
+ ); +} + +export const Default: Story = { + render: () => ( + +

Default modal

+

Native dialog with scroll lock, backdrop-click close, and ESC close.

+
+ ), +}; + +export const Brutalist: Story = { + render: () => ( + +

Brutalist modal

+

+ Blue background, hard border. Style is fully driven by the className you pass — Modal stays neutral. +

+
+ ), +}; diff --git a/src/shared/ui/Modal/ui/Modal.test.tsx b/src/shared/ui/Modal/ui/Modal.test.tsx new file mode 100644 index 0000000..edcc911 --- /dev/null +++ b/src/shared/ui/Modal/ui/Modal.test.tsx @@ -0,0 +1,145 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { Modal, type ModalHandle } from './Modal'; + +// jsdom does not implement dialog methods — mock them +beforeAll(() => { + HTMLDialogElement.prototype.showModal = vi.fn(); + HTMLDialogElement.prototype.close = vi.fn(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + document.body.style.overflow = ''; +}); + +describe('Modal', () => { + describe('rendering', () => { + it('renders children', () => { + render(child content); + expect(screen.getByText('child content')).toBeInTheDocument(); + }); + + it('forwards className to dialog element', () => { + render( + + x + , + ); + expect(document.querySelector('dialog')).toHaveClass('extra-class'); + }); + + it('sets aria-label on dialog', () => { + render(x); + expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My modal'); + }); + }); + + describe('imperative API', () => { + it('open() calls showModal', () => { + const ref = createRef(); + render( + + x + , + ); + ref.current?.open(); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledOnce(); + }); + + it('close() calls dialog.close', () => { + const ref = createRef(); + render( + + x + , + ); + ref.current?.close(); + expect(HTMLDialogElement.prototype.close).toHaveBeenCalledOnce(); + }); + }); + + describe('scroll lock', () => { + it('locks body scroll on open()', () => { + const ref = createRef(); + render( + + x + , + ); + ref.current?.open(); + expect(document.body.style.overflow).toBe('hidden'); + }); + + it('restores body scroll when the dialog close event fires', () => { + const ref = createRef(); + render( + + x + , + ); + ref.current?.open(); + const dialog = document.querySelector('dialog') as HTMLDialogElement; + fireEvent(dialog, new Event('close')); + expect(document.body.style.overflow).toBe(''); + }); + }); + + describe('backdrop interaction', () => { + it('closes on backdrop click (dialog element itself)', () => { + render(x); + const dialog = document.querySelector('dialog') as HTMLDialogElement; + fireEvent.click(dialog, { target: dialog }); + expect(HTMLDialogElement.prototype.close).toHaveBeenCalledOnce(); + }); + + it('does not close on click inside content', () => { + render( + + inner + , + ); + fireEvent.click(screen.getByText('inner')); + expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled(); + }); + + it('routes backdrop click through onBackdropClose when provided (skips default close)', () => { + const onBackdropClose = vi.fn(); + render( + + x + , + ); + const dialog = document.querySelector('dialog') as HTMLDialogElement; + fireEvent.click(dialog, { target: dialog }); + expect(onBackdropClose).toHaveBeenCalledOnce(); + expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled(); + }); + }); + + describe('callbacks', () => { + it('invokes onClose when close event fires', () => { + const onClose = vi.fn(); + render( + + x + , + ); + const dialog = document.querySelector('dialog') as HTMLDialogElement; + fireEvent(dialog, new Event('close')); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('invokes onCancel when cancel event fires', () => { + const onCancel = vi.fn(); + render( + + x + , + ); + const dialog = document.querySelector('dialog') as HTMLDialogElement; + fireEvent(dialog, new Event('cancel')); + expect(onCancel).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/src/shared/ui/Modal/ui/Modal.tsx b/src/shared/ui/Modal/ui/Modal.tsx new file mode 100644 index 0000000..4ac1b68 --- /dev/null +++ b/src/shared/ui/Modal/ui/Modal.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { + type DialogHTMLAttributes, + forwardRef, + type KeyboardEvent, + type MouseEvent, + useEffect, + useImperativeHandle, + useRef, +} from 'react'; +import { cn } from '$shared/lib'; + +export type ModalHandle = { + /** + * Opens the dialog as a modal and locks body scroll. + */ + open: () => void; + /** + * Closes the dialog. Body scroll is restored on the native `close` event, + * so any close path (ESC, backdrop, this method) restores it. + */ + close: () => void; +}; + +type Props = DialogHTMLAttributes & { + /** + * Called when the user activates the backdrop (click or Enter/Space). + * Replaces the default close — useful when the close path must be wrapped + * (e.g. in a view transition). When omitted, the dialog closes itself. + */ + onBackdropClose?: () => void; +}; + +/** + * Thin wrapper over native `` with `showModal()`. Locks body scroll + * while open, restores it on any close path, and treats backdrop clicks / + * Enter|Space on the backdrop as a close intent. Style the backdrop externally + * via a className on this component + a `::backdrop` selector. + * + * All standard dialog attributes pass through — `aria-label`, `onClose`, + * `onCancel`, etc. Use `onCancel` with `e.preventDefault()` to intercept ESC + * (e.g. to route close through a view transition wrapper). + */ +export const Modal = forwardRef(function Modal( + { className, onClick, onKeyUp, onBackdropClose, children, ...rest }, + ref, +) { + const dialogRef = useRef(null); + + /* Either run consumer-supplied close path (e.g. view-transition-wrapped) or + * fall back to closing the dialog directly. Shared between click and keyboard + * backdrop activation. */ + function triggerBackdropClose() { + if (onBackdropClose) { + onBackdropClose(); + } else { + dialogRef.current?.close(); + } + } + + useImperativeHandle(ref, () => ({ + open: () => { + document.body.style.overflow = 'hidden'; + dialogRef.current?.showModal(); + }, + close: () => { + dialogRef.current?.close(); + }, + })); + + useEffect(() => { + const el = dialogRef.current; + if (!el) { + return; + } + /* Scroll restore lives on the native event listener (not a React onClose + * prop) so it can't be unintentionally overridden by a caller that passes + * their own onClose. Both fire for the same close event. */ + const handleClose = () => { + document.body.style.overflow = ''; + }; + el.addEventListener('close', handleClose); + return () => el.removeEventListener('close', handleClose); + }, []); + + /** + * Closes the dialog when the user clicks the backdrop area directly. + * Target===currentTarget distinguishes the element itself (the + * backdrop hit-area) from its content children. Caller's onClick still runs. + */ + function handleClick(e: MouseEvent) { + if (e.target === e.currentTarget) { + triggerBackdropClose(); + } + onClick?.(e); + } + + /** + * Keyboard equivalent of backdrop click — Enter/Space on the backdrop area + * closes the dialog. ESC is handled natively by `showModal()`. + */ + function handleKeyUp(e: KeyboardEvent) { + if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { + triggerBackdropClose(); + } + onKeyUp?.(e); + } + + return ( + + {children} + + ); +}); diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 1f2e162..b8ec55c 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -9,6 +9,7 @@ export { InlineSvg } from './InlineSvg'; export { Input, Textarea } from './Input'; export type { LinkVariant } from './Link'; export { Link } from './Link'; +export { Modal, type ModalHandle } from './Modal'; export { RichText } from './RichText'; export type { ContainerSize, SectionBackground } from './Section'; export { Container, Section } from './Section'; From ecbb76312ba2e74a8bbf82cdc26329673cf36281 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 23 May 2026 09:53:26 +0300 Subject: [PATCH 12/19] refactor(section): snap-out old section-body on navigation The explicit fade-and-slide-left OLD animation left a visible ghost of the previous section's content behind the incoming slide. Replacing it with an instant opacity:0 keeps the transition clean while preserving the NEW slide-in delay so the snap-out has a beat to register. Drops the now-dead keyframes and --slide-section-body-out token. --- src/shared/styles/theme.css | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index 9015690..8e696e4 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -96,7 +96,6 @@ --duration-spring: 220ms; --delay-normal: 200ms; --slide-section-body-in: clamp(1.25rem, 5vw, 3rem); - --slide-section-body-out: clamp(0.5rem, 1.5vw, 0.75rem); } @theme inline { @@ -384,12 +383,13 @@ animation: none; } -/* Section body slide-in from right */ +/* Section body — snap OLD out and slide NEW in. Running an explicit OLD + * animation left a visible ghost of the previous section's content sitting + * behind the incoming slide; hiding it immediately keeps the transition + * clean and prevents the two-content overlap. */ ::view-transition-old(section-body) { - animation-name: section-body-out; - animation-duration: var(--duration-normal); - animation-timing-function: var(--ease-default); - animation-fill-mode: both; + animation: none; + opacity: 0; } ::view-transition-new(section-body) { @@ -397,20 +397,11 @@ animation-duration: var(--duration-spring); animation-timing-function: var(--ease-spring); animation-fill-mode: both; + /* Hold the start-state for this delay before the slide-in begins — gives + * the snap-out a beat to register visually before new content arrives. */ animation-delay: var(--delay-normal); } -@keyframes section-body-out { - from { - opacity: 1; - transform: translateX(0) scale(1); - } - to { - opacity: 0; - transform: translateX(calc(-1 * var(--slide-section-body-out))) scale(0.98); - } -} - @keyframes section-body-in { from { opacity: 0; From cd59766f926c2a0eb6b3b87fe53e4e43a38711c0 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 23 May 2026 09:54:20 +0300 Subject: [PATCH 13/19] feat(ImageLightbox): morph to dialog via View Transitions, fix sizing and stacking - Use the shared Modal for dialog mechanics; ImageLightbox keeps only the view-transition coordination (just-in-time view-transition-name handoff between thumb and dialog frame, ESC routed through onCancel). - Switch to for both thumb and dialog image; thumb is priority/sizes- driven for LCP, dialog image is lazy-loaded (deferred until open). - New brutal-outline utility for the dialog frame: outline paints after children so subpixel image bleed can't cover it; dialog gets overflow- visible so the outline isn't clipped. - lightbox-image utility caps image dimensions to viewport minus close-button and footer headroom, with box-sizing: content-box. - Lightbox dialog gets view-transition-name lightbox-dialog (z=20) and the frame gets lightbox-frame (z=30) so both stack cleanly above the page during the open/close transition. Footer drops its named VT group since section-body no longer slides over it. - Cream-tinted backdrop replaces the blur (Firefox-friendly), color-mix with var(--cream) for the token reference. - scrollbar-gutter: stable on html so locking body scroll doesn't shift the layout. --- src/shared/styles/theme.css | 58 +++++- .../ImageLightbox/ui/ImageLightbox.test.tsx | 21 ++ .../ui/ImageLightbox/ui/ImageLightbox.tsx | 184 ++++++++++++++---- 3 files changed, 215 insertions(+), 48 deletions(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index 8e696e4..513ab00 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -154,6 +154,9 @@ html { font-size: var(--font-size); scroll-behavior: smooth; + /* Reserve scrollbar gutter so locking body scroll (e.g. when a modal + * opens) doesn't widen the viewport and shift fixed elements. */ + scrollbar-gutter: stable; } body { @@ -272,6 +275,12 @@ @utility brutal-border-right { border-right: var(--border-width) solid var(--blue); } +/* Border drawn as an outline — painted after children, so an image's + * subpixel paint bleed can't cover it. Doesn't take layout space; the + * ancestor must not have overflow:hidden or the outline gets clipped. */ +@utility brutal-outline { + outline: var(--border-width) solid var(--blue); +} /* Apply Fraunces variable axes to non-heading elements using the heading font */ .font-wonk { font-variation-settings: @@ -413,9 +422,13 @@ } } -/* Keep footer above sliding section-body during view transitions */ +/* Page-load entry animation for the footer. Previously also carried + * `view-transition-name: site-footer` to layer above section-body's slide + * group, but the section-body group now has `animation: none` (purely + * horizontal slide of OLD/NEW snapshots), so the footer can live in the root + * snapshot during transitions — which also lets the lightbox dialog group + * stack cleanly above it. */ .footer-vt { - view-transition-name: site-footer; animation: footer-enter var(--duration-slow) var(--ease-spring) both; } @@ -430,12 +443,41 @@ } } -::view-transition-group(site-footer) { - z-index: 10; +/* Lightbox dialog backdrop — flat cream wash. No filter, no gradient. + * Cheapest possible; Firefox-friendly during view transitions. */ +dialog.lightbox::backdrop { + background-color: color-mix(in srgb, var(--cream) 80%, transparent); } -/* Lightbox dialog backdrop */ -dialog.lightbox::backdrop { - background-color: rgba(4, 28, 243, 0.25); - backdrop-filter: blur(4px); +/* Give the lightbox dialog its own view-transition group so it can stack + * above the footer's named group (z=10) during the open/close transition. + * Closed dialogs have `display: none` (UA) and don't get snapshotted, so + * sharing the name across all `.lightbox` dialogs at rest is harmless — + * only the open one participates in any given transition. */ +dialog.lightbox { + view-transition-name: lightbox-dialog; +} + +::view-transition-group(lightbox-dialog) { + z-index: 20; +} + +/* The image wrapper (and the thumb during the OLD snapshot of an open + * transition) shares this static name — imperatively assigned only during + * the morph, so there's never more than one element holding it at a time. + * z=30 keeps it above the dialog (z=20) and the footer (z=10) during VT. */ +::view-transition-group(lightbox-frame) { + z-index: 30; +} + +/* Lightbox image sizing — leaves vertical headroom for the fixed close button + * (sits at top-3, ~2.5rem tall). 8rem total = ~4rem top/bottom after centering, + * so the button never overlaps the image. + * box-sizing: content-box so max-w/max-h apply to the image pixels and the + * 3px brutal-border sits outside them — avoids subpixel clipping of the + * border by the dialog's overflow:hidden when both have border-box. */ +@utility lightbox-image { + box-sizing: content-box; + max-width: calc(100vw - 2rem); + max-height: calc(100vh - 8rem); } diff --git a/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx b/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx index 9fb2261..2bf7a99 100644 --- a/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx +++ b/src/shared/ui/ImageLightbox/ui/ImageLightbox.test.tsx @@ -9,6 +9,7 @@ beforeAll(() => { beforeEach(() => { vi.clearAllMocks(); + document.body.style.overflow = ''; }); const DEFAULT_PROPS = { src: '/project.jpg', alt: 'My Project' }; @@ -65,5 +66,25 @@ describe('ImageLightbox', () => { render(); expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My Project'); }); + + it('opening the lightbox blocks body scroll', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'My Project' })); + expect(document.body.style.overflow).toBe('hidden'); + }); + + it('closing the lightbox restores body scroll', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'My Project' })); + const dialog = document.querySelector('dialog') as HTMLDialogElement; + fireEvent(dialog, new Event('close')); + expect(document.body.style.overflow).toBe(''); + }); + + it('close button is positioned fixed', () => { + render(); + const closeBtn = screen.getByRole('button', { name: /close/i, hidden: true }); + expect(closeBtn).toHaveClass('fixed'); + }); }); }); diff --git a/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx b/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx index 73becfc..358080e 100644 --- a/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx +++ b/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx @@ -1,10 +1,11 @@ 'use client'; import Image from 'next/image'; -import { useRef } from 'react'; +import { type SyntheticEvent, useRef } from 'react'; import { CloseIcon } from '$shared/assets/icons'; import { cn } from '$shared/lib'; import { Button } from '$shared/ui/Button'; +import { Modal, type ModalHandle } from '$shared/ui/Modal'; type Props = { /** @@ -19,75 +20,178 @@ type Props = { * CSS classes forwarded to the thumbnail button wrapper */ className?: string; + /** + * Skip lazy-loading and preload the thumbnail. Set true for above-the-fold + * images to improve LCP. + * @default false + */ + priority?: boolean; + /** + * Responsive `sizes` attribute for the thumbnail. Without this, next/image + * `fill` defaults to `100vw` and the browser fetches the largest srcset + * variant. Tune to match the actual rendered width at each breakpoint. + * @default '(min-width: 1024px) 56rem, 100vw' + */ + sizes?: string; }; /** * Clickable image thumbnail that opens a fullscreen brutalist dialog on click. + * + * Uses the View Transitions API to morph the thumbnail's rect into the dialog + * frame's rect (and back on close). The view-transition-name is seated + * just-in-time around each transition rather than living on the thumb at rest + * — a persistent name would isolate the thumb from any parent transition + * (e.g. the section-body slide-in), causing the image to snap into place + * while the rest of the section animates. */ -export function ImageLightbox({ src, alt, className }: Props) { - const dialogRef = useRef(null); +export function ImageLightbox({ + src, + alt, + className, + priority = false, + sizes = '(min-width: 1024px) 56rem, 100vw', +}: Props) { + const modalRef = useRef(null); + const thumbRef = useRef(null); + const dialogFrameRef = useRef(null); + /* Shared static name across all instances. Only one dialog can be open at a + * time (showModal is browser-exclusive), and we set the name imperatively + * only during a transition — so at any snapshot, exactly one element has it. */ + const vtName = 'lightbox-frame'; + + /** + * Drops the view-transition-name from both thumb and dialog frame. Called + * after the lightbox close transition settles (and as a safety net on any + * unexpected close path) so the thumb rejoins parent transitions. + */ + function clearVtNames() { + if (thumbRef.current) { + thumbRef.current.style.viewTransitionName = ''; + } + if (dialogFrameRef.current) { + dialogFrameRef.current.style.viewTransitionName = ''; + } + } + + /** + * Runs `mutate` inside a view transition when supported; falls back to a + * plain synchronous call otherwise (Firefox without VT support, jsdom). + * Returns the transition handle so callers can await `finished` for cleanup. + */ + function withTransition(mutate: () => void): { finished: Promise } | null { + const doc = document as Document & { + startViewTransition?: (cb: () => void) => { finished: Promise }; + }; + if (typeof doc.startViewTransition === 'function') { + return doc.startViewTransition(mutate); + } + mutate(); + return null; + } function open() { - dialogRef.current?.showModal(); + /* Seat the name on the thumb *before* startViewTransition so it's + * captured in the OLD snapshot. The thumb otherwise carries no vt-name. */ + if (thumbRef.current) { + thumbRef.current.style.viewTransitionName = vtName; + } + withTransition(() => { + if (thumbRef.current) { + thumbRef.current.style.viewTransitionName = ''; + } + if (dialogFrameRef.current) { + dialogFrameRef.current.style.viewTransitionName = vtName; + } + modalRef.current?.open(); + }); } function close() { - dialogRef.current?.close(); - } - - /** - * Closes the dialog when the user clicks the backdrop area directly. - * Comparing target to currentTarget distinguishes a click on the - * element itself (the backdrop) from a click on its content children. - */ - function handleBackdropClick(e: React.MouseEvent) { - if (e.target === e.currentTarget) { - close(); + const transition = withTransition(() => { + if (dialogFrameRef.current) { + dialogFrameRef.current.style.viewTransitionName = ''; + } + if (thumbRef.current) { + thumbRef.current.style.viewTransitionName = vtName; + } + modalRef.current?.close(); + }); + /* Drop the name from the thumb once the transition settles. Otherwise the + * thumb stays its own snapshot until the next open, isolated from any + * parent transition that runs in the meantime. */ + if (transition) { + transition.finished.finally(clearVtNames); + } else { + clearVtNames(); } } /** - * Keyboard equivalent of backdrop click — closes the dialog when the user - * activates the backdrop area (dialog element itself) via Enter or Space. - * ESC is handled natively by showModal(); this covers explicit backdrop activation. + * Intercept ESC so it also runs through our view-transition-wrapped close. + * Without this, ESC would snap the dialog away without the morph. */ - function handleBackdropKeyUp(e: React.KeyboardEvent) { - if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { - close(); - } + function handleCancel(e: SyntheticEvent) { + e.preventDefault(); + close(); } return ( <> - - - {/* Native img so the dialog sizes to the image — next/image fill requires a pre-sized container */} - {/* aria-hidden: the dialog element itself carries the accessible label */} - {/* eslint-disable-next-line @next/next/no-img-element */} - {alt} - + + + {/* Wrapper carries the border as an `outline` (not `border`) — paint + * order is border→children→outline, so an `outline` is drawn ON TOP + * of any subpixel image bleed and stays fully visible. The dialog + * uses `overflow-visible` because outline can be clipped by an + * ancestor's overflow:hidden. + * The wrapper is the named VT element so the brutalist frame + * participates in the morph. + * aria-hidden: the dialog element itself carries the accessible label. */} +
+ {/* Explicit width/height are placeholders next/image requires when not using `fill`; CSS + * (`lightbox-image` max-w/max-h + `w-auto h-auto`) drives the actual rendered size, so + * the dialog still hugs the image's intrinsic dimensions (capped at viewport bounds). + * `sizes="100vw"` hints the browser to fetch the srcset variant matching viewport width. */} + {alt} +
+ -
+ ); } From 9ebb515032905a61a52944633be71bc29119d217 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 23 May 2026 09:54:43 +0300 Subject: [PATCH 14/19] feat(projects): prioritize LCP image, fix View Project button on mobile - ProjectCard accepts a `priority` prop forwarded to the thumbnail Image so above-the-fold cards skip lazy-loading and get a preload hint. - ProjectsSection marks the first card *with an image* as priority; handles the case where the first project has no image and the LCP candidate ends up being a later card. - View Project button drops `w-full` on mobile (collapsed sidebar above the card body), using `self-start` + `text-center` instead so it sizes to its content. Restores column-filling on lg+ where the sidebar is its own narrow column. --- .../project/ui/ProjectCard/ProjectCard.tsx | 14 +++++++++++--- .../ui/ProjectsSection/ProjectsSection.tsx | 8 +++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/entities/project/ui/ProjectCard/ProjectCard.tsx b/src/entities/project/ui/ProjectCard/ProjectCard.tsx index 2af0474..f498486 100644 --- a/src/entities/project/ui/ProjectCard/ProjectCard.tsx +++ b/src/entities/project/ui/ProjectCard/ProjectCard.tsx @@ -26,6 +26,12 @@ type Props = { * Optional preview image URL */ imageUrl?: string; + /** + * Skip lazy-loading the preview image. Set true for above-the-fold cards + * (typically the first card in the list) to improve LCP. + * @default false + */ + priority?: boolean; }; /** @@ -33,7 +39,7 @@ type Props = { * Sidebar: year badge, stack tags, View Project button. * Main: title, optional image, description. */ -export function ProjectCard({ title, year, description, tags, url, imageUrl }: Props) { +export function ProjectCard({ title, year, description, tags, url, imageUrl, priority = false }: Props) { return ( )} -
@@ -57,7 +63,9 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl }: P >
{title} - {imageUrl && } + {imageUrl && ( + + )}
diff --git a/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx b/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx index f71d3e0..97da991 100644 --- a/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx +++ b/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx @@ -13,9 +13,14 @@ export default async function ProjectsSection() { tags: ['projects'], }); + /* Mark the first project that actually has an image as LCP-priority. + * Using `index === 0` alone misses the case where the first card has no + * image and the LCP candidate ends up being the next card's image. */ + const lcpIndex = items.findIndex((project) => project.image); + return (
- {items.map((project) => ( + {items.map((project, index) => ( ))}
From 532f93d8967c7833574a875625fc0309b5bf4e9e Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 23 May 2026 12:52:16 +0300 Subject: [PATCH 15/19] fix: disable lightbox animation in firefox since it isnt supported yet --- src/shared/styles/theme.css | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index 513ab00..2979bc8 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -479,5 +479,16 @@ dialog.lightbox { @utility lightbox-image { box-sizing: content-box; max-width: calc(100vw - 2rem); - max-height: calc(100vh - 8rem); + max-height: calc(100vh - 10rem); +} + +/* Disable transition animation for Firefox + * since it isn't supported yet */ +@supports (-moz-appearance: none) { + ::view-transition-group(lightbox-frame), + ::view-transition-old(lightbox-frame), + ::view-transition-new(lightbox-frame) { + animation-duration: 0s !important; + animation-delay: 0s !important; + } } From 521aa7d05c6579059e395fd319ab2e069e7fb833 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 23 May 2026 12:53:37 +0300 Subject: [PATCH 16/19] feat: create new grain-pattern utility and use it for body --- src/shared/styles/theme.css | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index 2979bc8..df1a44b 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -167,30 +167,16 @@ overflow-x: hidden; } - /* Subtle blue-tinted grain on parchment */ + /* Page-wide blue dot grain overlay. z-index 100 puts it above the footer + * (z-50) so the grain reads as continuous across the entire viewport; + * pointer-events: none keeps everything clickable through it. */ body::before { + @apply grain-pattern; content: ""; position: fixed; inset: 0; - background-image: - repeating-linear-gradient( - 0deg, - transparent, - transparent 2px, - rgba(4, 28, 243, 0.015) 2px, - rgba(4, 28, 243, 0.015) 4px - ), - repeating-linear-gradient( - 90deg, - transparent, - transparent 2px, - rgba(4, 28, 243, 0.015) 2px, - rgba(4, 28, 243, 0.015) 4px - ); - opacity: 0.6; - display: block; pointer-events: none; - z-index: 1; + z-index: 100; } h1, @@ -281,6 +267,12 @@ @utility brutal-outline { outline: var(--border-width) solid var(--blue); } +/* Tiled blue dot pattern — applied to body::before (page-wide) and reusable + * on any surface that should share the same paper-grain texture. The SVG + * tile is rasterized once and composited cheaply via repeating background. */ +@utility grain-pattern { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Ccircle cx='1' cy='1' r='1' fill='%23041cf3' opacity='0.10'/%3E%3C/svg%3E"); +} /* Apply Fraunces variable axes to non-heading elements using the heading font */ .font-wonk { font-variation-settings: From 7e87cbc3ae05421997a438ae510f6e71eb79dd8e Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 23 May 2026 13:00:56 +0300 Subject: [PATCH 17/19] chore: experience card responsive style tweak --- src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx b/src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx index d5f3d71..01d7afa 100644 --- a/src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx +++ b/src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx @@ -37,9 +37,9 @@ export function ExperienceCard({ title, company, period, description, stack, cla +

{period}

-

{company}

+

{company}

{stack.length > 0 && (
{stack.map((tech) => ( From 83ddd2724f818916c2820c1d8c0f6f258d019793 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 23 May 2026 13:06:56 +0300 Subject: [PATCH 18/19] chore: enforce common prop typing style --- app/[[...slug]]/page.tsx | 4 ++-- src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx | 4 ++-- .../project/ui/DetailedProjectCard/DetailedProjectCard.tsx | 4 ++-- src/entities/project/ui/ProjectCard/ProjectCard.tsx | 4 ++-- src/entities/project/ui/ProjectMetadata/ProjectMetadata.tsx | 4 ++-- src/shared/assets/icons/CloseIcon.tsx | 4 ++-- src/shared/assets/icons/MagnifyIcon.tsx | 4 ++-- src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx | 4 ++-- src/shared/ui/InlineSvg/ui/InlineSvg.tsx | 4 ++-- src/shared/ui/Modal/ui/Modal.tsx | 4 ++-- src/shared/ui/RichText/ui/RichText.tsx | 4 ++-- .../ui/ViewTransitionWrapper/ui/ViewTransitionWrapper.tsx | 4 ++-- .../ui/SectionErrorBoundary/SectionErrorBoundary.tsx | 4 ++-- .../ui/SectionsAccordion/SectionsAccordion.tsx | 4 ++-- 14 files changed, 28 insertions(+), 28 deletions(-) diff --git a/app/[[...slug]]/page.tsx b/app/[[...slug]]/page.tsx index 4fa0ef0..8bb725a 100644 --- a/app/[[...slug]]/page.tsx +++ b/app/[[...slug]]/page.tsx @@ -19,9 +19,9 @@ export async function generateStaticParams() { } } -type Props = { +export interface Props { params: Promise<{ slug?: string[] }>; -}; +} /** * Portfolio page — one route per section, sections list always visible. diff --git a/src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx b/src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx index 01d7afa..1208d4d 100644 --- a/src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx +++ b/src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx @@ -1,6 +1,6 @@ import { Badge, Card, CardSidebar, CardTitle, RichText } from '$shared/ui'; -type Props = { +export interface Props { /** * Job title */ @@ -25,7 +25,7 @@ type Props = { * Additional CSS classes forwarded to the card */ className?: string; -}; +} /** * Work experience card with sidebar layout. diff --git a/src/entities/project/ui/DetailedProjectCard/DetailedProjectCard.tsx b/src/entities/project/ui/DetailedProjectCard/DetailedProjectCard.tsx index f4365ef..fba5a5c 100644 --- a/src/entities/project/ui/DetailedProjectCard/DetailedProjectCard.tsx +++ b/src/entities/project/ui/DetailedProjectCard/DetailedProjectCard.tsx @@ -2,7 +2,7 @@ import Image from 'next/image'; import { Card, RichText } from '$shared/ui'; import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata'; -type Props = { +export interface Props { /** * Project name */ @@ -36,7 +36,7 @@ type Props = { * @default false */ reverse?: boolean; -}; +} /** * Full-width detailed project card with metadata sidebar. diff --git a/src/entities/project/ui/ProjectCard/ProjectCard.tsx b/src/entities/project/ui/ProjectCard/ProjectCard.tsx index f498486..6a1cd63 100644 --- a/src/entities/project/ui/ProjectCard/ProjectCard.tsx +++ b/src/entities/project/ui/ProjectCard/ProjectCard.tsx @@ -1,7 +1,7 @@ import { cn } from '$shared/lib'; import { Badge, Button, Card, CardSidebar, CardTitle, ImageLightbox, RichText } from '$shared/ui'; -type Props = { +export interface Props { /** * Project name */ @@ -32,7 +32,7 @@ type Props = { * @default false */ priority?: boolean; -}; +} /** * Project card with sidebar layout. diff --git a/src/entities/project/ui/ProjectMetadata/ProjectMetadata.tsx b/src/entities/project/ui/ProjectMetadata/ProjectMetadata.tsx index 9722b83..4cc58ec 100644 --- a/src/entities/project/ui/ProjectMetadata/ProjectMetadata.tsx +++ b/src/entities/project/ui/ProjectMetadata/ProjectMetadata.tsx @@ -1,6 +1,6 @@ import { cn } from '$shared/lib'; -type Props = { +export interface Props { /** * Project year */ @@ -17,7 +17,7 @@ type Props = { * Additional CSS classes */ className?: string; -}; +} /** * Sidebar metadata display for a project: year, role, and stack. diff --git a/src/shared/assets/icons/CloseIcon.tsx b/src/shared/assets/icons/CloseIcon.tsx index 722f9e5..6a5ca73 100644 --- a/src/shared/assets/icons/CloseIcon.tsx +++ b/src/shared/assets/icons/CloseIcon.tsx @@ -1,9 +1,9 @@ -type Props = { +export interface Props { /** * CSS classes on the svg element */ className?: string; -}; +} /** * Close / X icon (Lucide). diff --git a/src/shared/assets/icons/MagnifyIcon.tsx b/src/shared/assets/icons/MagnifyIcon.tsx index d6197cc..0491147 100644 --- a/src/shared/assets/icons/MagnifyIcon.tsx +++ b/src/shared/assets/icons/MagnifyIcon.tsx @@ -1,9 +1,9 @@ -type Props = { +export interface Props { /** * CSS classes on the svg element */ className?: string; -}; +} /** * Magnify / search icon (Lucide). diff --git a/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx b/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx index 358080e..02d9a38 100644 --- a/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx +++ b/src/shared/ui/ImageLightbox/ui/ImageLightbox.tsx @@ -7,7 +7,7 @@ import { cn } from '$shared/lib'; import { Button } from '$shared/ui/Button'; import { Modal, type ModalHandle } from '$shared/ui/Modal'; -type Props = { +export interface Props { /** * Image source URL */ @@ -33,7 +33,7 @@ type Props = { * @default '(min-width: 1024px) 56rem, 100vw' */ sizes?: string; -}; +} /** * Clickable image thumbnail that opens a fullscreen brutalist dialog on click. diff --git a/src/shared/ui/InlineSvg/ui/InlineSvg.tsx b/src/shared/ui/InlineSvg/ui/InlineSvg.tsx index 182dad4..a988764 100644 --- a/src/shared/ui/InlineSvg/ui/InlineSvg.tsx +++ b/src/shared/ui/InlineSvg/ui/InlineSvg.tsx @@ -1,7 +1,7 @@ import parse from 'html-react-parser'; import { cn } from '$shared/lib'; -type Props = { +export interface Props { /** * SVG markup string to inline as React elements */ @@ -10,7 +10,7 @@ type Props = { * Additional CSS classes on the wrapper span */ className?: string; -}; +} /** * Parses an SVG markup string into React elements. diff --git a/src/shared/ui/Modal/ui/Modal.tsx b/src/shared/ui/Modal/ui/Modal.tsx index 4ac1b68..8df4b2a 100644 --- a/src/shared/ui/Modal/ui/Modal.tsx +++ b/src/shared/ui/Modal/ui/Modal.tsx @@ -23,14 +23,14 @@ export type ModalHandle = { close: () => void; }; -type Props = DialogHTMLAttributes & { +export interface Props extends DialogHTMLAttributes { /** * Called when the user activates the backdrop (click or Enter/Space). * Replaces the default close — useful when the close path must be wrapped * (e.g. in a view transition). When omitted, the dialog closes itself. */ onBackdropClose?: () => void; -}; +} /** * Thin wrapper over native `` with `showModal()`. Locks body scroll diff --git a/src/shared/ui/RichText/ui/RichText.tsx b/src/shared/ui/RichText/ui/RichText.tsx index 36b88fb..5136c97 100644 --- a/src/shared/ui/RichText/ui/RichText.tsx +++ b/src/shared/ui/RichText/ui/RichText.tsx @@ -1,7 +1,7 @@ import parse from 'html-react-parser'; import { cn } from '$shared/lib'; -type Props = { +export interface Props { /** * HTML string from PocketBase rich-text editor */ @@ -10,7 +10,7 @@ type Props = { * Additional CSS classes merged onto the wrapper div */ className?: string; -}; +} /** * Renders a PocketBase rich-text HTML string as React elements. diff --git a/src/shared/ui/ViewTransitionWrapper/ui/ViewTransitionWrapper.tsx b/src/shared/ui/ViewTransitionWrapper/ui/ViewTransitionWrapper.tsx index 3aad3d4..b3c478f 100644 --- a/src/shared/ui/ViewTransitionWrapper/ui/ViewTransitionWrapper.tsx +++ b/src/shared/ui/ViewTransitionWrapper/ui/ViewTransitionWrapper.tsx @@ -6,7 +6,7 @@ import { Fragment, type ReactNode, ViewTransition as VT } from 'react'; */ const Transition = (VT ?? Fragment) as typeof VT; -type Props = { +export interface Props { /** * Maps to the view-transition-name CSS property */ @@ -15,7 +15,7 @@ type Props = { * Content to animate */ children: ReactNode; -}; +} /** * Wraps children in React's ViewTransition when available, diff --git a/src/widgets/SectionFactory/ui/SectionErrorBoundary/SectionErrorBoundary.tsx b/src/widgets/SectionFactory/ui/SectionErrorBoundary/SectionErrorBoundary.tsx index bf72206..1a5934a 100644 --- a/src/widgets/SectionFactory/ui/SectionErrorBoundary/SectionErrorBoundary.tsx +++ b/src/widgets/SectionFactory/ui/SectionErrorBoundary/SectionErrorBoundary.tsx @@ -3,12 +3,12 @@ import type { ErrorInfo, ReactNode } from 'react'; import { Component } from 'react'; -type Props = { +export interface Props { /** * Section content to render */ children: ReactNode; -}; +} type State = { /** diff --git a/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx b/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx index 6e8f74b..106523a 100644 --- a/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx +++ b/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx @@ -3,7 +3,7 @@ import { Children } from 'react'; import type { SectionRecord } from '$entities/Section'; import { SectionAccordion } from '$entities/Section'; -type Props = { +export interface Props { /** * Ordered section metadata — drives navigation labels and IDs */ @@ -17,7 +17,7 @@ type Props = { * Pre-rendered RSC content slots, one per section, matched by index */ children: ReactNode; -}; +} /** * Renders all portfolio sections as an accordion list. From e16b88ba7e2930f8ad03e35383ab1f554336f61b Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 23 May 2026 13:14:06 +0300 Subject: [PATCH 19/19] chore: remove outdated code --- src/widgets/Navigation/index.ts | 4 - src/widgets/Navigation/model/types.ts | 14 --- .../Navigation/ui/MobileNav.stories.tsx | 28 ------ src/widgets/Navigation/ui/MobileNav.test.tsx | 62 ------------- src/widgets/Navigation/ui/MobileNav.tsx | 63 -------------- .../Navigation/ui/SidebarNav.stories.tsx | 29 ------- src/widgets/Navigation/ui/SidebarNav.test.tsx | 84 ------------------ src/widgets/Navigation/ui/SidebarNav.tsx | 87 ------------------- .../Navigation/ui/UtilityBar.stories.tsx | 20 ----- src/widgets/Navigation/ui/UtilityBar.test.tsx | 29 ------- src/widgets/Navigation/ui/UtilityBar.tsx | 32 ------- src/widgets/index.ts | 1 - 12 files changed, 453 deletions(-) delete mode 100644 src/widgets/Navigation/index.ts delete mode 100644 src/widgets/Navigation/model/types.ts delete mode 100644 src/widgets/Navigation/ui/MobileNav.stories.tsx delete mode 100644 src/widgets/Navigation/ui/MobileNav.test.tsx delete mode 100644 src/widgets/Navigation/ui/MobileNav.tsx delete mode 100644 src/widgets/Navigation/ui/SidebarNav.stories.tsx delete mode 100644 src/widgets/Navigation/ui/SidebarNav.test.tsx delete mode 100644 src/widgets/Navigation/ui/SidebarNav.tsx delete mode 100644 src/widgets/Navigation/ui/UtilityBar.stories.tsx delete mode 100644 src/widgets/Navigation/ui/UtilityBar.test.tsx delete mode 100644 src/widgets/Navigation/ui/UtilityBar.tsx diff --git a/src/widgets/Navigation/index.ts b/src/widgets/Navigation/index.ts deleted file mode 100644 index d7cb7c5..0000000 --- a/src/widgets/Navigation/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type { NavItem } from './model/types'; -export { MobileNav } from './ui/MobileNav'; -export { SidebarNav } from './ui/SidebarNav'; -export { UtilityBar } from './ui/UtilityBar'; diff --git a/src/widgets/Navigation/model/types.ts b/src/widgets/Navigation/model/types.ts deleted file mode 100644 index c8e0334..0000000 --- a/src/widgets/Navigation/model/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type NavItem = { - /** - * Section HTML id for anchor scrolling - */ - id: string; - /** - * Display label - */ - label: string; - /** - * Display number prefix (e.g. "01") - */ - number: string; -}; diff --git a/src/widgets/Navigation/ui/MobileNav.stories.tsx b/src/widgets/Navigation/ui/MobileNav.stories.tsx deleted file mode 100644 index 8b44421..0000000 --- a/src/widgets/Navigation/ui/MobileNav.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite'; -import { MobileNav } from './MobileNav'; - -// MobileNav is lg:hidden — it renders only on mobile viewports. -// Use the viewport toolbar in Storybook to switch to a mobile size to see it. -const meta: Meta = { - title: 'Widgets/MobileNav', - component: MobileNav, - parameters: { - viewport: { - defaultViewport: 'mobile1', - }, - }, -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - items: [ - { id: 'bio', label: 'Bio', number: '01' }, - { id: 'work', label: 'Work', number: '02' }, - { id: 'contact', label: 'Contact', number: '03' }, - ], - }, -}; diff --git a/src/widgets/Navigation/ui/MobileNav.test.tsx b/src/widgets/Navigation/ui/MobileNav.test.tsx deleted file mode 100644 index 3d4036a..0000000 --- a/src/widgets/Navigation/ui/MobileNav.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type { NavItem } from '../model/types'; -import { MobileNav } from './MobileNav'; - -vi.mock('next/navigation', () => ({ usePathname: vi.fn(() => '/') })); - -const ITEMS: NavItem[] = [ - { id: 'intro', label: 'Intro', number: '01' }, - { id: 'bio', label: 'Bio', number: '02' }, -]; - -describe('MobileNav', () => { - describe('rendering', () => { - it('renders title "allmy.work"', () => { - render(); - expect(screen.getByText('allmy.work')).toBeInTheDocument(); - }); - - it('renders toggle button with text "Menu" initially', () => { - render(); - expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument(); - }); - - it('menu items are hidden initially', () => { - render(); - expect(screen.queryByRole('link', { name: /intro/i })).not.toBeInTheDocument(); - }); - }); - - describe('navigation items', () => { - it('shows items as links with correct hrefs when open', async () => { - render(); - await userEvent.click(screen.getByRole('button', { name: 'Menu' })); - expect(screen.getByRole('link', { name: /01.*Intro/i })).toHaveAttribute('href', '/intro'); - expect(screen.getByRole('link', { name: /02.*Bio/i })).toHaveAttribute('href', '/bio'); - }); - }); - - describe('interactions', () => { - it('click toggle shows links and changes label to "Close"', async () => { - render(); - await userEvent.click(screen.getByRole('button', { name: 'Menu' })); - expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); - expect(screen.getByText('Intro')).toBeInTheDocument(); - }); - - it('closes menu when pathname changes', async () => { - const { usePathname } = await import('next/navigation'); - vi.mocked(usePathname).mockReturnValue('/'); - const { rerender } = render(); - await userEvent.click(screen.getByRole('button', { name: 'Menu' })); - expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); - - vi.mocked(usePathname).mockReturnValue('/bio'); - rerender(); - - expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); - }); - }); -}); diff --git a/src/widgets/Navigation/ui/MobileNav.tsx b/src/widgets/Navigation/ui/MobileNav.tsx deleted file mode 100644 index ea4e808..0000000 --- a/src/widgets/Navigation/ui/MobileNav.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import { cn } from '$shared/lib'; -import type { NavItem } from '../model/types'; - -/** - * Props for MobileNav. - */ -interface Props { - /** - * Navigation items to render - */ - items: NavItem[]; -} - -/** - * Mobile navigation overlay, hidden on lg+ screens. - * Closes automatically when the URL pathname changes after navigation. - */ -export function MobileNav({ items }: Props) { - const [isOpen, setIsOpen] = useState(false); - const pathname = usePathname(); - - // biome-ignore lint/correctness/useExhaustiveDependencies: pathname is the trigger, not a value used inside the callback - useEffect(() => { - setIsOpen(false); - }, [pathname]); - - return ( -
-
-

allmy.work

- -
- {isOpen && ( -
- {items.map((item) => ( - -
- {item.number} - - {item.label} - -
- - ))} -
- )} -
- ); -} diff --git a/src/widgets/Navigation/ui/SidebarNav.stories.tsx b/src/widgets/Navigation/ui/SidebarNav.stories.tsx deleted file mode 100644 index 2c98381..0000000 --- a/src/widgets/Navigation/ui/SidebarNav.stories.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite'; -import { SidebarNav } from './SidebarNav'; - -// SidebarNav is hidden lg:block — it renders only on desktop viewports. -// Use the viewport toolbar in Storybook to switch to a desktop size to see it. -const meta: Meta = { - title: 'Widgets/SidebarNav', - component: SidebarNav, - parameters: { - layout: 'fullscreen', - viewport: { - defaultViewport: 'desktop', - }, - }, -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - items: [ - { id: 'bio', label: 'Bio', number: '01' }, - { id: 'work', label: 'Work', number: '02' }, - { id: 'contact', label: 'Contact', number: '03' }, - ], - }, -}; diff --git a/src/widgets/Navigation/ui/SidebarNav.test.tsx b/src/widgets/Navigation/ui/SidebarNav.test.tsx deleted file mode 100644 index 10d16f3..0000000 --- a/src/widgets/Navigation/ui/SidebarNav.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import type { NavItem } from '../model/types'; -import { SidebarNav } from './SidebarNav'; - -vi.mock('next/navigation', () => ({ - usePathname: vi.fn(), -})); - -import { usePathname } from 'next/navigation'; - -const ITEMS: NavItem[] = [ - { id: 'bio', label: 'Bio', number: '01' }, - { id: 'work', label: 'Work', number: '02' }, -]; - -describe('SidebarNav', () => { - describe('rendering', () => { - beforeEach(() => { - vi.mocked(usePathname).mockReturnValue('/bio'); - }); - - it('renders a nav element', () => { - render(); - expect(screen.getByRole('navigation')).toBeInTheDocument(); - }); - - it('renders "Index" heading', () => { - render(); - expect(screen.getByText('Index')).toBeInTheDocument(); - }); - - it('renders "Digital Monograph" subtitle', () => { - render(); - expect(screen.getByText('Digital Monograph')).toBeInTheDocument(); - }); - - it('renders each item label and number', () => { - render(); - expect(screen.getByText('Bio')).toBeInTheDocument(); - expect(screen.getByText('01')).toBeInTheDocument(); - expect(screen.getByText('Work')).toBeInTheDocument(); - expect(screen.getByText('02')).toBeInTheDocument(); - }); - - it('renders "Quick Links" section', () => { - render(); - expect(screen.getByText('Quick Links')).toBeInTheDocument(); - }); - - it('renders Email quick link', () => { - render(); - expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument(); - }); - - it('renders a link for each nav item', () => { - render(); - expect(screen.getByRole('link', { name: /Bio/i })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /Work/i })).toBeInTheDocument(); - }); - }); - - describe('active state', () => { - it('marks matching pathname item as active (no opacity-40)', () => { - vi.mocked(usePathname).mockReturnValue('/bio'); - render(); - const activeLink = screen.getByRole('link', { name: /Bio/i }); - expect(activeLink).not.toHaveClass('opacity-40'); - }); - - it('marks non-matching item as inactive (opacity-40)', () => { - vi.mocked(usePathname).mockReturnValue('/bio'); - render(); - const inactiveLink = screen.getByRole('link', { name: /Work/i }); - expect(inactiveLink).toHaveClass('opacity-40'); - }); - - it('marks first item active at root path', () => { - vi.mocked(usePathname).mockReturnValue('/'); - render(); - const firstLink = screen.getByRole('link', { name: /Bio/i }); - expect(firstLink).not.toHaveClass('opacity-40'); - }); - }); -}); diff --git a/src/widgets/Navigation/ui/SidebarNav.tsx b/src/widgets/Navigation/ui/SidebarNav.tsx deleted file mode 100644 index d48543d..0000000 --- a/src/widgets/Navigation/ui/SidebarNav.tsx +++ /dev/null @@ -1,87 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { CONTACT_LINKS, cn } from '$shared/lib'; -import type { NavItem } from '../model/types'; - -/** - * Props for SidebarNav. - */ -interface Props { - /** - * Navigation items to render - */ - items: NavItem[]; -} - -/** - * Fixed sidebar navigation, visible on lg+ screens. - * Active section determined by current URL pathname. - */ -export function SidebarNav({ items }: Props) { - const pathname = usePathname(); - - /** - * An item is active when its slug matches the current pathname, - * or when the pathname is root and it is the first item. - */ - function isActive(item: NavItem): boolean { - if (pathname === `/${item.id}`) { - return true; - } - if (pathname === '/' && items[0]?.id === item.id) { - return true; - } - return false; - } - - return ( - - ); -} diff --git a/src/widgets/Navigation/ui/UtilityBar.stories.tsx b/src/widgets/Navigation/ui/UtilityBar.stories.tsx deleted file mode 100644 index df7cf2b..0000000 --- a/src/widgets/Navigation/ui/UtilityBar.stories.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite'; -import { UtilityBar } from './UtilityBar'; - -const meta: Meta = { - title: 'Widgets/UtilityBar', - component: UtilityBar, - decorators: [ - (Story) => ( -
- -
- ), - ], -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/src/widgets/Navigation/ui/UtilityBar.test.tsx b/src/widgets/Navigation/ui/UtilityBar.test.tsx deleted file mode 100644 index d2d0ecf..0000000 --- a/src/widgets/Navigation/ui/UtilityBar.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { UtilityBar } from './UtilityBar'; - -describe('UtilityBar', () => { - describe('rendering', () => { - it('renders "Contact" label', () => { - render(); - expect(screen.getByText('Contact')).toBeInTheDocument(); - }); - - it('renders email link with correct href', () => { - render(); - const link = screen.getByRole('link', { name: 'hello@allmy.work' }); - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work'); - }); - - it('renders "Download CV" button', () => { - render(); - expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument(); - }); - - it('Download CV button has primary variant class', () => { - render(); - const btn = screen.getByRole('button', { name: /download cv/i }); - expect(btn).toHaveClass('bg-blue'); - }); - }); -}); diff --git a/src/widgets/Navigation/ui/UtilityBar.tsx b/src/widgets/Navigation/ui/UtilityBar.tsx deleted file mode 100644 index 3e93030..0000000 --- a/src/widgets/Navigation/ui/UtilityBar.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client'; - -import { CONTACT_LINKS } from '$shared/lib'; -import { Button } from '$shared/ui'; - -/** - * Fixed bottom utility bar with contact info and CV download. - */ -export function UtilityBar() { - /** - * Handles CV download action. - */ - function handleDownloadCV() { - console.log('Downloading CV...'); - } - - return ( -
-
- - -
-
- ); -} diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 71ffc2e..ddcc5a9 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -1,2 +1 @@ export * from './Footer'; -export * from './Navigation';