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