Merge pull request 'Feature/image dialog' (#8) from feature/image-dialog into main
Build and push / build (push) Successful in 1m7s

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-05-23 10:16:54 +00:00
43 changed files with 858 additions and 540 deletions
+2 -2
View File
@@ -19,9 +19,9 @@ export async function generateStaticParams() {
} }
} }
type Props = { export interface Props {
params: Promise<{ slug?: string[] }>; params: Promise<{ slug?: string[] }>;
}; }
/** /**
* Portfolio page — one route per section, sections list always visible. * Portfolio page — one route per section, sections list always visible.
+1 -1
View File
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
*/ */
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en" data-scroll-behavior="smooth">
<body className={`${fraunces.variable} ${publicSans.variable} pb-footer md:pb-footer-wide`}> <body className={`${fraunces.variable} ${publicSans.variable} pb-footer md:pb-footer-wide`}>
{children} {children}
<Footer /> <Footer />
@@ -55,10 +55,5 @@ describe('SectionAccordion', () => {
render(<SectionAccordion {...activeProps} />); render(<SectionAccordion {...activeProps} />);
expect(screen.queryByRole('link')).not.toBeInTheDocument(); expect(screen.queryByRole('link')).not.toBeInTheDocument();
}); });
it('content wrapper has section-content class', () => {
const { container } = render(<SectionAccordion {...activeProps} />);
expect(container.querySelector('.section-content')).toBeInTheDocument();
});
}); });
}); });
@@ -39,13 +39,13 @@ 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-6 sm:mb-12"> <div className="mb-6 sm:mb-12">
<ViewTransitionWrapper name="section-content"> <ViewTransitionWrapper name="section-title">
<div className="mb-6 sm:mb-16"> <div className="mb-6 sm:mb-12">
<h1 className="font-heading font-black text-xl sm:text-section-title leading-[1.2] mb-0">{heading}</h1> <h1 className="font-heading font-black text-xl sm:text-section-title leading-[1.2] mb-0">{heading}</h1>
</div> </div>
</ViewTransitionWrapper> </ViewTransitionWrapper>
<ViewTransitionWrapper name="section-body"> <ViewTransitionWrapper name="section-body">
<div className="section-content">{children}</div> <div>{children}</div>
</ViewTransitionWrapper> </ViewTransitionWrapper>
</div> </div>
) : ( ) : (
@@ -1,6 +1,6 @@
import { Badge, Card, CardSidebar, CardTitle, RichText } from '$shared/ui'; import { Badge, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
type Props = { export interface Props {
/** /**
* Job title * Job title
*/ */
@@ -25,7 +25,7 @@ type Props = {
* Additional CSS classes forwarded to the card * Additional CSS classes forwarded to the card
*/ */
className?: string; className?: string;
}; }
/** /**
* Work experience card with sidebar layout. * Work experience card with sidebar layout.
@@ -37,9 +37,9 @@ export function ExperienceCard({ title, company, period, description, stack, cla
<Card className={className}> <Card className={className}>
<CardSidebar <CardSidebar
sidebar={ sidebar={
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-3 sm:gap-4">
<p className="text-sm font-medium brutal-border-left pl-3">{period}</p> <p className="text-sm font-medium brutal-border-left pl-3">{period}</p>
<p className="text-base font-medium">{company}</p> <p className="text-lg font-black">{company}</p>
{stack.length > 0 && ( {stack.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{stack.map((tech) => ( {stack.map((tech) => (
@@ -2,7 +2,7 @@ import Image from 'next/image';
import { Card, RichText } from '$shared/ui'; import { Card, RichText } from '$shared/ui';
import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata'; import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata';
type Props = { export interface Props {
/** /**
* Project name * Project name
*/ */
@@ -36,7 +36,7 @@ type Props = {
* @default false * @default false
*/ */
reverse?: boolean; reverse?: boolean;
}; }
/** /**
* Full-width detailed project card with metadata sidebar. * Full-width detailed project card with metadata sidebar.
@@ -124,5 +124,11 @@ describe('ProjectCard', () => {
const imgWrapper = container.querySelector('img')?.parentElement; const imgWrapper = container.querySelector('img')?.parentElement;
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border'); expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
}); });
it('image is wrapped in a lightbox button with cursor-zoom-in', () => {
render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
const btn = screen.getByRole('button', { name: DEFAULT_PROPS.title });
expect(btn).toHaveClass('cursor-zoom-in');
});
}); });
}); });
@@ -1,8 +1,7 @@
import Image from 'next/image';
import { cn } from '$shared/lib'; 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 = { export interface Props {
/** /**
* Project name * Project name
*/ */
@@ -27,14 +26,20 @@ type Props = {
* Optional preview image URL * Optional preview image URL
*/ */
imageUrl?: string; 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;
}
/** /**
* Project card with sidebar layout. * Project card with sidebar layout.
* Sidebar: year badge, stack tags, View Project button. * Sidebar: year badge, stack tags, View Project button.
* Main: title, optional image, description. * 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 ( return (
<Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}> <Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}>
<CardSidebar <CardSidebar
@@ -50,7 +55,7 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl }: P
))} ))}
</div> </div>
)} )}
<Button href={url} variant="primary" size="sm" className="w-full"> <Button href={url} variant="primary" size="sm" className="self-start lg:w-full lg:self-auto text-center">
View Project View Project
</Button> </Button>
</div> </div>
@@ -59,9 +64,7 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl }: P
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<CardTitle className="font-heading">{title}</CardTitle> <CardTitle className="font-heading">{title}</CardTitle>
{imageUrl && ( {imageUrl && (
<div className="brutal-border aspect-video bg-blue overflow-hidden relative"> <ImageLightbox src={imageUrl} alt={title} priority={priority} className="brutal-border bg-blue" />
<Image src={imageUrl} alt={title} fill className="object-cover" />
</div>
)} )}
<RichText html={description} /> <RichText html={description} />
</div> </div>
@@ -1,6 +1,6 @@
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
type Props = { export interface Props {
/** /**
* Project year * Project year
*/ */
@@ -17,7 +17,7 @@ type Props = {
* Additional CSS classes * Additional CSS classes
*/ */
className?: string; className?: string;
}; }
/** /**
* Sidebar metadata display for a project: year, role, and stack. * Sidebar metadata display for a project: year, role, and stack.
+30
View File
@@ -0,0 +1,30 @@
export interface Props {
/**
* CSS classes on the svg element
*/
className?: string;
}
/**
* Close / X icon (Lucide).
*/
export function CloseIcon({ className }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={true}
className={className}
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
}
+30
View File
@@ -0,0 +1,30 @@
export interface Props {
/**
* CSS classes on the svg element
*/
className?: string;
}
/**
* Magnify / search icon (Lucide).
*/
export function MagnifyIcon({ className }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={true}
className={className}
>
<path d="m21 21-4.34-4.34" />
<circle cx="11" cy="11" r="8" />
</svg>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { CloseIcon } from './CloseIcon';
export { MagnifyIcon } from './MagnifyIcon';
+104 -42
View File
@@ -96,7 +96,6 @@
--duration-spring: 220ms; --duration-spring: 220ms;
--delay-normal: 200ms; --delay-normal: 200ms;
--slide-section-body-in: clamp(1.25rem, 5vw, 3rem); --slide-section-body-in: clamp(1.25rem, 5vw, 3rem);
--slide-section-body-out: clamp(0.5rem, 1.5vw, 0.75rem);
} }
@theme inline { @theme inline {
@@ -154,6 +153,10 @@
html { html {
font-size: var(--font-size); 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 { body {
@@ -164,30 +167,16 @@
overflow-x: hidden; 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 { body::before {
@apply grain-pattern;
content: ""; content: "";
position: fixed; position: fixed;
inset: 0; 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; pointer-events: none;
z-index: 1; z-index: 100;
} }
h1, h1,
@@ -272,6 +261,18 @@
@utility brutal-border-right { @utility brutal-border-right {
border-right: var(--border-width) solid var(--blue); 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);
}
/* 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 */ /* Apply Fraunces variable axes to non-heading elements using the heading font */
.font-wonk { .font-wonk {
font-variation-settings: font-variation-settings:
@@ -340,14 +341,14 @@
} }
/* Cross-section view transition (navigation between sections) */ /* Cross-section view transition (navigation between sections) */
::view-transition-old(section-content) { ::view-transition-old(section-title) {
animation-name: section-fade-out; animation-name: section-fade-out;
animation-duration: var(--duration-normal); animation-duration: var(--duration-normal);
animation-timing-function: var(--ease-default); animation-timing-function: var(--ease-default);
animation-fill-mode: both; animation-fill-mode: both;
} }
::view-transition-new(section-content) { ::view-transition-new(section-title) {
animation-name: section-fade-in; animation-name: section-fade-in;
animation-duration: var(--duration-spring); animation-duration: var(--duration-spring);
animation-timing-function: var(--ease-spring); animation-timing-function: var(--ease-spring);
@@ -376,12 +377,20 @@
} }
} }
/* Section body slide-in from right */ /* 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 — 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) { ::view-transition-old(section-body) {
animation-name: section-body-out; animation: none;
animation-duration: var(--duration-normal); opacity: 0;
animation-timing-function: var(--ease-default);
animation-fill-mode: both;
} }
::view-transition-new(section-body) { ::view-transition-new(section-body) {
@@ -389,20 +398,11 @@
animation-duration: var(--duration-spring); animation-duration: var(--duration-spring);
animation-timing-function: var(--ease-spring); animation-timing-function: var(--ease-spring);
animation-fill-mode: both; 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); 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 { @keyframes section-body-in {
from { from {
opacity: 0; opacity: 0;
@@ -414,11 +414,73 @@
} }
} }
/* 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 { .footer-vt {
view-transition-name: site-footer; animation: footer-enter var(--duration-slow) var(--ease-spring) both;
} }
::view-transition-group(site-footer) { @keyframes footer-enter {
z-index: 10; from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 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);
}
/* 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 - 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;
}
} }
+2 -2
View File
@@ -24,11 +24,11 @@ describe('Button', () => {
}); });
it('applies outline variant', () => { it('applies outline variant', () => {
render(<Button variant="outline">Go</Button>); render(<Button variant="outline">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-transparent'); expect(screen.getByRole('button')).toHaveClass('bg-cream');
}); });
it('applies ghost variant', () => { it('applies ghost variant', () => {
render(<Button variant="ghost">Go</Button>); render(<Button variant="ghost">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-cream'); expect(screen.getByRole('button')).toHaveClass('bg-transparent');
}); });
}); });
describe('sizes', () => { describe('sizes', () => {
+2 -2
View File
@@ -46,9 +46,9 @@ const VARIANTS = {
secondary: 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', '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: 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', '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<ButtonVariant, string>; } as const satisfies Record<ButtonVariant, string>;
const SIZES = { const SIZES = {
+1
View File
@@ -0,0 +1 @@
export { ImageLightbox } from './ui/ImageLightbox';
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { ImageLightbox } from './ImageLightbox';
const meta: Meta<typeof ImageLightbox> = {
title: 'Shared/ImageLightbox',
component: ImageLightbox,
};
export default meta;
type Story = StoryObj<typeof ImageLightbox>;
export const Default: Story = {
args: {
src: 'https://picsum.photos/800/450',
alt: 'Sample project image',
},
decorators: [
(Story) => (
<div className="p-8 max-w-2xl">
<Story />
</div>
),
],
};
@@ -0,0 +1,90 @@
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();
document.body.style.overflow = '';
});
const DEFAULT_PROPS = { src: '/project.jpg', alt: 'My Project' };
describe('ImageLightbox', () => {
describe('thumbnail', () => {
it('renders a thumbnail image', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
expect(screen.getByRole('img', { name: 'My Project' })).toBeInTheDocument();
});
it('thumbnail button has cursor-zoom-in', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
const btn = screen.getByRole('button', { name: 'My Project' });
expect(btn).toHaveClass('cursor-zoom-in');
});
it('forwards className to the thumbnail button', () => {
render(<ImageLightbox {...DEFAULT_PROPS} className="extra-class" />);
expect(screen.getByRole('button', { name: 'My Project' })).toHaveClass('extra-class');
});
});
describe('dialog', () => {
it('clicking the thumbnail opens the dialog', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
fireEvent.click(screen.getByRole('button', { name: 'My Project' }));
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1);
});
it('clicking the close button closes the dialog', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
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(<ImageLightbox {...DEFAULT_PROPS} />);
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(<ImageLightbox {...DEFAULT_PROPS} />);
const dialog = document.querySelector('dialog') as HTMLDialogElement;
const inner = dialog.querySelector('img') as HTMLImageElement;
fireEvent.click(inner);
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
});
it('dialog has accessible label matching alt text', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My Project');
});
it('opening the lightbox blocks body scroll', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
fireEvent.click(screen.getByRole('button', { name: 'My Project' }));
expect(document.body.style.overflow).toBe('hidden');
});
it('closing the lightbox restores body scroll', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
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(<ImageLightbox {...DEFAULT_PROPS} />);
const closeBtn = screen.getByRole('button', { name: /close/i, hidden: true });
expect(closeBtn).toHaveClass('fixed');
});
});
});
@@ -0,0 +1,197 @@
'use client';
import Image from 'next/image';
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';
export interface 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;
/**
* 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,
priority = false,
sizes = '(min-width: 1024px) 56rem, 100vw',
}: Props) {
const modalRef = useRef<ModalHandle>(null);
const thumbRef = useRef<HTMLButtonElement>(null);
const dialogFrameRef = useRef<HTMLDivElement>(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<void> } | null {
const doc = document as Document & {
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
};
if (typeof doc.startViewTransition === 'function') {
return doc.startViewTransition(mutate);
}
mutate();
return null;
}
function open() {
/* 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() {
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();
}
}
/**
* Intercept ESC so it also runs through our view-transition-wrapped close.
* Without this, ESC would snap the dialog away without the morph.
*/
function handleCancel(e: SyntheticEvent<HTMLDialogElement>) {
e.preventDefault();
close();
}
return (
<>
<button
ref={thumbRef}
type="button"
onClick={open}
aria-label={alt}
className={cn('relative block w-full aspect-video overflow-hidden cursor-zoom-in', className)}
>
<Image
src={src}
alt={alt}
fill
loading={priority ? 'eager' : undefined}
priority={priority}
sizes={sizes}
className="object-cover"
/>
</button>
<Modal
ref={modalRef}
aria-label={alt}
className="lightbox bg-cream overflow-visible"
onClose={clearVtNames}
onCancel={handleCancel}
onBackdropClose={close}
>
{/* 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. */}
<div ref={dialogFrameRef} className="brutal-outline block w-fit">
{/* 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. */}
<Image
src={src}
alt={alt}
width={2000}
height={2000}
loading="lazy"
sizes="100vw"
aria-hidden={true}
className="lightbox-image block w-auto h-auto"
/>
</div>
<Button variant="outline" size="sm" onClick={close} aria-label="Close image" className="fixed top-3 right-3">
<CloseIcon />
</Button>
</Modal>
</>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import parse from 'html-react-parser'; import parse from 'html-react-parser';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
type Props = { export interface Props {
/** /**
* SVG markup string to inline as React elements * SVG markup string to inline as React elements
*/ */
@@ -10,7 +10,7 @@ type Props = {
* Additional CSS classes on the wrapper span * Additional CSS classes on the wrapper span
*/ */
className?: string; className?: string;
}; }
/** /**
* Parses an SVG markup string into React elements. * Parses an SVG markup string into React elements.
+1
View File
@@ -0,0 +1 @@
export { Modal, type ModalHandle } from './ui/Modal';
+56
View File
@@ -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<typeof Modal> = {
title: 'Shared/Modal',
component: Modal,
};
export default meta;
type Story = StoryObj<typeof Modal>;
/**
* 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<ModalHandle>(null);
return (
<div className="p-8">
<Button onClick={() => modalRef.current?.open()}>Open modal</Button>
<Modal ref={modalRef} aria-label="Story modal" className={className}>
<div className="p-8 max-w-md">
{children}
<div className="mt-4">
<Button variant="outline" size="sm" onClick={() => modalRef.current?.close()}>
Close
</Button>
</div>
</div>
</Modal>
</div>
);
}
export const Default: Story = {
render: () => (
<TriggerWrapper className="bg-cream brutal-border">
<h3 className="mb-2">Default modal</h3>
<p>Native dialog with scroll lock, backdrop-click close, and ESC close.</p>
</TriggerWrapper>
),
};
export const Brutalist: Story = {
render: () => (
<TriggerWrapper className="bg-blue text-cream brutal-border">
<h3 className="mb-2 text-cream">Brutalist modal</h3>
<p className="text-cream">
Blue background, hard border. Style is fully driven by the className you pass Modal stays neutral.
</p>
</TriggerWrapper>
),
};
+145
View File
@@ -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(<Modal aria-label="Test">child content</Modal>);
expect(screen.getByText('child content')).toBeInTheDocument();
});
it('forwards className to dialog element', () => {
render(
<Modal aria-label="Test" className="extra-class">
x
</Modal>,
);
expect(document.querySelector('dialog')).toHaveClass('extra-class');
});
it('sets aria-label on dialog', () => {
render(<Modal aria-label="My modal">x</Modal>);
expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My modal');
});
});
describe('imperative API', () => {
it('open() calls showModal', () => {
const ref = createRef<ModalHandle>();
render(
<Modal ref={ref} aria-label="Test">
x
</Modal>,
);
ref.current?.open();
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledOnce();
});
it('close() calls dialog.close', () => {
const ref = createRef<ModalHandle>();
render(
<Modal ref={ref} aria-label="Test">
x
</Modal>,
);
ref.current?.close();
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledOnce();
});
});
describe('scroll lock', () => {
it('locks body scroll on open()', () => {
const ref = createRef<ModalHandle>();
render(
<Modal ref={ref} aria-label="Test">
x
</Modal>,
);
ref.current?.open();
expect(document.body.style.overflow).toBe('hidden');
});
it('restores body scroll when the dialog close event fires', () => {
const ref = createRef<ModalHandle>();
render(
<Modal ref={ref} aria-label="Test">
x
</Modal>,
);
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(<Modal aria-label="Test">x</Modal>);
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(
<Modal aria-label="Test">
<span>inner</span>
</Modal>,
);
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(
<Modal aria-label="Test" onBackdropClose={onBackdropClose}>
x
</Modal>,
);
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(
<Modal aria-label="Test" onClose={onClose}>
x
</Modal>,
);
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(
<Modal aria-label="Test" onCancel={onCancel}>
x
</Modal>,
);
const dialog = document.querySelector('dialog') as HTMLDialogElement;
fireEvent(dialog, new Event('cancel'));
expect(onCancel).toHaveBeenCalledOnce();
});
});
});
+121
View File
@@ -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;
};
export interface Props extends DialogHTMLAttributes<HTMLDialogElement> {
/**
* 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 `<dialog>` 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<ModalHandle, Props>(function Modal(
{ className, onClick, onKeyUp, onBackdropClose, children, ...rest },
ref,
) {
const dialogRef = useRef<HTMLDialogElement>(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 <dialog> element itself (the
* backdrop hit-area) from its content children. Caller's onClick still runs.
*/
function handleClick(e: MouseEvent<HTMLDialogElement>) {
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<HTMLDialogElement>) {
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
triggerBackdropClose();
}
onKeyUp?.(e);
}
return (
<dialog
ref={dialogRef}
{...rest}
className={cn('fixed inset-0 m-auto p-0 overflow-hidden', className)}
onClick={handleClick}
onKeyUp={handleKeyUp}
>
{children}
</dialog>
);
});
+2 -2
View File
@@ -1,7 +1,7 @@
import parse from 'html-react-parser'; import parse from 'html-react-parser';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
type Props = { export interface Props {
/** /**
* HTML string from PocketBase rich-text editor * HTML string from PocketBase rich-text editor
*/ */
@@ -10,7 +10,7 @@ type Props = {
* Additional CSS classes merged onto the wrapper div * Additional CSS classes merged onto the wrapper div
*/ */
className?: string; className?: string;
}; }
/** /**
* Renders a PocketBase rich-text HTML string as React elements. * Renders a PocketBase rich-text HTML string as React elements.
@@ -6,7 +6,7 @@ import { Fragment, type ReactNode, ViewTransition as VT } from 'react';
*/ */
const Transition = (VT ?? Fragment) as typeof VT; const Transition = (VT ?? Fragment) as typeof VT;
type Props = { export interface Props {
/** /**
* Maps to the view-transition-name CSS property * Maps to the view-transition-name CSS property
*/ */
@@ -15,7 +15,7 @@ type Props = {
* Content to animate * Content to animate
*/ */
children: ReactNode; children: ReactNode;
}; }
/** /**
* Wraps children in React's ViewTransition when available, * Wraps children in React's ViewTransition when available,
+2 -1
View File
@@ -4,11 +4,12 @@ export type { ButtonSize, ButtonVariant } from './Button';
export { Button } from './Button'; export { Button } from './Button';
export type { CardBackground } from './Card'; export type { CardBackground } from './Card';
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card'; export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
export { ImageLightbox } from './ImageLightbox';
export { InlineSvg } from './InlineSvg'; export { InlineSvg } from './InlineSvg';
export { Input, Textarea } from './Input'; export { Input, Textarea } from './Input';
export type { LinkVariant } from './Link'; export type { LinkVariant } from './Link';
export { Link } from './Link'; export { Link } from './Link';
export { Modal, type ModalHandle } from './Modal';
export { RichText } from './RichText'; 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';
-4
View File
@@ -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';
-14
View File
@@ -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;
};
@@ -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<typeof MobileNav> = {
title: 'Widgets/MobileNav',
component: MobileNav,
parameters: {
viewport: {
defaultViewport: 'mobile1',
},
},
};
export default meta;
type Story = StoryObj<typeof MobileNav>;
export const Default: Story = {
args: {
items: [
{ id: 'bio', label: 'Bio', number: '01' },
{ id: 'work', label: 'Work', number: '02' },
{ id: 'contact', label: 'Contact', number: '03' },
],
},
};
@@ -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(<MobileNav items={ITEMS} />);
expect(screen.getByText('allmy.work')).toBeInTheDocument();
});
it('renders toggle button with text "Menu" initially', () => {
render(<MobileNav items={ITEMS} />);
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
});
it('menu items are hidden initially', () => {
render(<MobileNav items={ITEMS} />);
expect(screen.queryByRole('link', { name: /intro/i })).not.toBeInTheDocument();
});
});
describe('navigation items', () => {
it('shows items as links with correct hrefs when open', async () => {
render(<MobileNav items={ITEMS} />);
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(<MobileNav items={ITEMS} />);
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(<MobileNav items={ITEMS} />);
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
vi.mocked(usePathname).mockReturnValue('/bio');
rerender(<MobileNav items={ITEMS} />);
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
});
});
});
-63
View File
@@ -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 (
<div className="lg:hidden fixed top-0 left-0 right-0 bg-cream brutal-border-bottom z-50">
<div className="px-6 py-4 flex items-center justify-between">
<h4>allmy.work</h4>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="brutal-border px-4 py-2 bg-blue text-cream"
>
{isOpen ? 'Close' : 'Menu'}
</button>
</div>
{isOpen && (
<div className="px-6 py-6 brutal-border-top space-y-2 max-h-[80vh] overflow-y-auto">
{items.map((item) => (
<Link key={item.id} href={`/${item.id}`} className="block w-full brutal-border bg-cream px-4 py-3">
<div className={cn('flex items-baseline gap-3')}>
<span className="text-sm opacity-60 font-body">{item.number}</span>
<span
className="font-heading text-lg font-black"
style={{ fontVariationSettings: '"WONK" 1, "SOFT" 0' }}
>
{item.label}
</span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
@@ -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<typeof SidebarNav> = {
title: 'Widgets/SidebarNav',
component: SidebarNav,
parameters: {
layout: 'fullscreen',
viewport: {
defaultViewport: 'desktop',
},
},
};
export default meta;
type Story = StoryObj<typeof SidebarNav>;
export const Default: Story = {
args: {
items: [
{ id: 'bio', label: 'Bio', number: '01' },
{ id: 'work', label: 'Work', number: '02' },
{ id: 'contact', label: 'Contact', number: '03' },
],
},
};
@@ -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(<SidebarNav items={ITEMS} />);
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
it('renders "Index" heading', () => {
render(<SidebarNav items={ITEMS} />);
expect(screen.getByText('Index')).toBeInTheDocument();
});
it('renders "Digital Monograph" subtitle', () => {
render(<SidebarNav items={ITEMS} />);
expect(screen.getByText('Digital Monograph')).toBeInTheDocument();
});
it('renders each item label and number', () => {
render(<SidebarNav items={ITEMS} />);
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(<SidebarNav items={ITEMS} />);
expect(screen.getByText('Quick Links')).toBeInTheDocument();
});
it('renders Email quick link', () => {
render(<SidebarNav items={ITEMS} />);
expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
});
it('renders a link for each nav item', () => {
render(<SidebarNav items={ITEMS} />);
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(<SidebarNav items={ITEMS} />);
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(<SidebarNav items={ITEMS} />);
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(<SidebarNav items={ITEMS} />);
const firstLink = screen.getByRole('link', { name: /Bio/i });
expect(firstLink).not.toHaveClass('opacity-40');
});
});
});
-87
View File
@@ -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 (
<nav className="fixed top-0 left-0 h-screen w-full lg:w-1/3 bg-cream brutal-border-right hidden lg:block overflow-y-auto z-50">
<div className="px-8 py-12 space-y-2">
<div className="mb-12">
<h2>Index</h2>
<div className="brutal-border-top pt-4">
<p className="text-sm opacity-60">Digital Monograph</p>
</div>
</div>
{items.map((item) => (
<Link
key={item.id}
href={`/${item.id}`}
className={cn(
'block w-full text-left brutal-border bg-cream px-6 py-4 transition-all duration-300',
isActive(item)
? 'shadow-brutal-2xl opacity-100 translate-x-0'
: 'opacity-40 shadow-none hover:opacity-60',
)}
>
<div className="flex items-baseline gap-4">
<span className="text-sm opacity-60">{item.number}</span>
<span className="font-heading text-xl font-black">{item.label}</span>
</div>
</Link>
))}
<div className="mt-12 pt-12 brutal-border-top">
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
<div className="space-y-3">
<a href={`mailto:${CONTACT_LINKS.email}`} className="block">
Email
</a>
<a href={CONTACT_LINKS.linkedin} className="block">
LinkedIn
</a>
<a href={CONTACT_LINKS.instagram} className="block">
Instagram
</a>
<a href={CONTACT_LINKS.arena} className="block">
Are.na
</a>
</div>
</div>
</div>
</nav>
);
}
@@ -1,20 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { UtilityBar } from './UtilityBar';
const meta: Meta<typeof UtilityBar> = {
title: 'Widgets/UtilityBar',
component: UtilityBar,
decorators: [
(Story) => (
<div className="relative h-24 bg-ochre-clay">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof UtilityBar>;
export const Default: Story = {};
@@ -1,29 +0,0 @@
import { render, screen } from '@testing-library/react';
import { UtilityBar } from './UtilityBar';
describe('UtilityBar', () => {
describe('rendering', () => {
it('renders "Contact" label', () => {
render(<UtilityBar />);
expect(screen.getByText('Contact')).toBeInTheDocument();
});
it('renders email link with correct href', () => {
render(<UtilityBar />);
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(<UtilityBar />);
expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument();
});
it('Download CV button has primary variant class', () => {
render(<UtilityBar />);
const btn = screen.getByRole('button', { name: /download cv/i });
expect(btn).toHaveClass('bg-blue');
});
});
});
-32
View File
@@ -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 (
<div className="fixed bottom-0 left-0 right-0 bg-cream brutal-border-top z-40">
<div className="max-w-[2560px] mx-auto px-6 md:px-12 lg:px-16 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-sm uppercase tracking-wider">Contact</span>
<a href={`mailto:${CONTACT_LINKS.email}`} className="text-base hover:opacity-60 transition-opacity">
{CONTACT_LINKS.email}
</a>
</div>
<Button variant="primary" size="sm" onClick={handleDownloadCV}>
Download CV
</Button>
</div>
</div>
);
}
@@ -13,9 +13,14 @@ export default async function ProjectsSection() {
tags: ['projects'], 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 ( return (
<div className="space-y-6 max-w-section"> <div className="space-y-6 max-w-section">
{items.map((project) => ( {items.map((project, index) => (
<ProjectCard <ProjectCard
key={project.id} key={project.id}
title={project.title} title={project.title}
@@ -24,6 +29,7 @@ export default async function ProjectsSection() {
tags={project.stack} tags={project.stack}
url={project.url} url={project.url}
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined} imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
priority={index === lcpIndex}
/> />
))} ))}
</div> </div>
@@ -3,12 +3,12 @@
import type { ErrorInfo, ReactNode } from 'react'; import type { ErrorInfo, ReactNode } from 'react';
import { Component } from 'react'; import { Component } from 'react';
type Props = { export interface Props {
/** /**
* Section content to render * Section content to render
*/ */
children: ReactNode; children: ReactNode;
}; }
type State = { type State = {
/** /**
@@ -3,7 +3,7 @@ import { Children } from 'react';
import type { SectionRecord } from '$entities/Section'; import type { SectionRecord } from '$entities/Section';
import { SectionAccordion } from '$entities/Section'; import { SectionAccordion } from '$entities/Section';
type Props = { export interface Props {
/** /**
* Ordered section metadata drives navigation labels and IDs * 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 * Pre-rendered RSC content slots, one per section, matched by index
*/ */
children: ReactNode; children: ReactNode;
}; }
/** /**
* Renders all portfolio sections as an accordion list. * Renders all portfolio sections as an accordion list.
-1
View File
@@ -1,2 +1 @@
export * from './Footer'; export * from './Footer';
export * from './Navigation';
+1 -1
View File
@@ -1,5 +1,5 @@
import path from 'node:path';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({