Compare commits

..

7 commits

Author SHA1 Message Date
383a0229b3 Reorder photos
All checks were successful
Check & deploy / validate (push) Successful in 9m5s
Check & deploy / deploy (push) Successful in 9m32s
2026-06-02 20:05:02 +01:00
23c5485e52 Center button 2026-06-02 20:03:36 +01:00
200ae187c7 Fix centering 2026-06-02 20:00:48 +01:00
00fa298dd5 Add loading animatino 2026-06-02 19:56:35 +01:00
9272fd43a5 Update test 2026-06-02 19:13:18 +01:00
50291182ae Update catalog 2026-06-02 19:09:03 +01:00
64d10b5dbf Update photos 2026-06-02 18:54:56 +01:00
37 changed files with 247 additions and 85 deletions

View file

@ -5,7 +5,7 @@ test('renders the gallery and supports keyboard navigation', async ({
}) => { }) => {
await page.goto('/'); await page.goto('/');
await expect(page.locator('.thumbnail')).toHaveCount(54); await expect(page.locator('.thumbnail')).toHaveCount(47);
await expect(page.locator('#frame-image')).toBeVisible(); await expect(page.locator('#frame-image')).toBeVisible();
const firstAlt = await page.locator('#frame-image').getAttribute('alt'); const firstAlt = await page.locator('#frame-image').getAttribute('alt');

View file

@ -116,7 +116,9 @@ const renderThumbnail = (photo, isCurrent) => {
</a>`; </a>`;
}; };
const renderFrame = (photo) => `<figure class="frame-figure"> const renderFrame = (
photo
) => `<figure class="frame-figure" style="--frame-ratio:${photo.aspectRatio}">
${renderPicture( ${renderPicture(
photo, photo,
frameSizes(photo), frameSizes(photo),

View file

@ -144,25 +144,67 @@ img {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
display: grid; display: grid;
place-items: center;
// A full-width column gives the figure a definite width to size against, so
// `100%` below resolves to the real available width (not the image's own,
// content-collapsed width) even before the photo has loaded.
grid-template-columns: minmax(0, 1fr);
align-content: center;
justify-items: center;
position: relative;
} }
.frame-content.is-loading::after {
content: '';
position: absolute;
inset: 0;
margin: auto;
inline-size: 3rem;
block-size: 3rem;
border: 4px solid rgb(32 33 36 / 20%);
border-block-start-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.frame-content.is-loading::after {
animation: none;
}
}
// Holds the next photo's <picture> off-screen so the browser fetches it before
// the slideshow advances. Kept renderable (not display: none) so loading runs.
.frame-preload {
position: absolute;
inline-size: 0;
block-size: 0;
overflow: hidden;
pointer-events: none;
}
// Size the framed photo to its final, contained dimensions up front width is
// capped by the available width (100%) and by the height limit scaled by the
// photo's aspect ratio — so the white frame never grows from a tiny box to the
// photo's size while the image is still loading. --frame-ratio is set per photo.
.frame-figure { .frame-figure {
min-width: 0;
margin: 0; margin: 0;
display: grid; inline-size: min(100%, calc((100dvh - 5rem) * var(--frame-ratio, 1)));
justify-items: center;
} }
.frame-figure picture { .frame-figure picture {
display: grid; display: block;
place-items: center;
min-width: 0;
} }
.frame-figure img { .frame-figure img {
inline-size: auto; inline-size: 100%;
max-inline-size: 100%; block-size: auto;
max-block-size: calc(100dvh - 5rem); max-block-size: calc(100dvh - 5rem);
border: var(--border-width) solid var(--color-text); border: var(--border-width) solid var(--color-text);
background: var(--color-text); background: var(--color-text);
@ -174,8 +216,7 @@ img {
display: none; display: none;
position: absolute; position: absolute;
inset-block-start: var(--space-s); inset-block-start: var(--space-s);
inset-inline-start: var(--space-s); justify-self: center;
justify-self: start;
border: 0; border: 0;
border-radius: 4px; border-radius: 4px;
padding: 0.45rem 0.75rem; padding: 0.45rem 0.75rem;
@ -206,7 +247,9 @@ img {
} }
.slideshow-toggle { .slideshow-toggle {
inset-block-start: 50%;
inset-inline: auto var(--space-s); inset-inline: auto var(--space-s);
transform: translateY(-50%);
} }
.site-header h1 { .site-header h1 {
@ -257,6 +300,10 @@ img {
padding: var(--space-s); padding: var(--space-s);
} }
.frame-figure {
inline-size: min(100%, calc((100dvh - 12rem) * var(--frame-ratio, 1)));
}
.frame-figure img { .frame-figure img {
max-block-size: calc(100dvh - 12rem); max-block-size: calc(100dvh - 12rem);
} }

View file

@ -1,24 +1,44 @@
[ [
{ {
"file": "_DSC1190-Edit.jpg", "file": "_DSC2821-iceland.jpg",
"alt": "Portrait of a man in a burgundy sleeveless shirt standing with folded arms above a city view." "alt": "A steep slope of rust-coloured grass below a snow-dusted rocky peak, with a dark gravel riverbed in the foreground."
}, },
{ {
"file": "_DSC1484.jpg", "file": "_DSC2879-iceland.jpg",
"alt": "A man smiling while drying a plate in a kitchen." "alt": "A snow-covered mountain with its summit lost in cloud, a fence line crossing the white plain below."
},
{
"file": "_DSC2975-iceland.jpg",
"alt": "A glacier tongue meeting a still, icy lagoon beneath misty dark mountains."
},
{
"file": "_DSC3105-iceland.jpg",
"alt": "A snow- and ice-covered river canyon winding toward the horizon under a clear blue sky."
},
{
"file": "2025-NYC-31.jpg",
"alt": "Looking up at glass-and-steel skyscrapers converging against a pale sky."
},
{
"file": "2025-NYC-41.jpg",
"alt": "A pigeon in sharp close-up gazes toward the camera as glass towers rise behind it under an overcast sky."
},
{
"file": "2025-NYC-175.jpg",
"alt": "The rusted wreck of an overturned vehicle lying among bare trees and rocks in stark black-and-white woodland."
},
{
"file": "2025-NYC-194.jpg",
"alt": "Bare winter trees growing from a rocky, boulder-strewn hillside in soft black-and-white light."
},
{
"file": "2025-NYC-232.jpg",
"alt": "A rocky cliff topped with bare winter trees beneath a pale sky crossed by aeroplane contrails."
}, },
{ {
"file": "_DSC1834.jpg", "file": "_DSC1834.jpg",
"alt": "A person sitting near dark rocks while waves crash into a sea cave." "alt": "A person sitting near dark rocks while waves crash into a sea cave."
}, },
{
"file": "_DSC2313.jpg",
"alt": "Portrait of a man in a teal coat standing among autumn leaves."
},
{
"file": "_DSC2362.jpg",
"alt": "Portrait of a man with glasses leaning on a railing in a park."
},
{ {
"file": "_DSC2674.jpg", "file": "_DSC2674.jpg",
"alt": "A person in a hooded jacket walking down a narrow city street." "alt": "A person in a hooded jacket walking down a narrow city street."
@ -27,14 +47,6 @@
"file": "_DSC8718-Edit.jpg", "file": "_DSC8718-Edit.jpg",
"alt": "Defocused city lights glowing across a dark night skyline." "alt": "Defocused city lights glowing across a dark night skyline."
}, },
{
"file": "adam.jpg",
"alt": "A man standing against a dark background in a black graphic T-shirt."
},
{
"file": "dorka-1.jpg",
"alt": "A woman in a white knit hat sitting outdoors on an autumn day."
},
{ {
"file": "kezdetazarovonalbol.jpg", "file": "kezdetazarovonalbol.jpg",
"alt": "A small plant stem silhouetted against warm yellow and orange light." "alt": "A small plant stem silhouetted against warm yellow and orange light."
@ -51,10 +63,6 @@
"file": "nyomtatas-10.jpg", "file": "nyomtatas-10.jpg",
"alt": "Warm abstract light passing through dried plants." "alt": "Warm abstract light passing through dried plants."
}, },
{
"file": "web-1-2.jpg",
"alt": "A city panorama seen through a coin-operated viewing telescope."
},
{ {
"file": "web-1.jpg", "file": "web-1.jpg",
"alt": "Black and white stage scene with dancers performing before an audience." "alt": "Black and white stage scene with dancers performing before an audience."
@ -63,14 +71,6 @@
"file": "web-2-2.jpg", "file": "web-2-2.jpg",
"alt": "Two zebras walking across a black and white landscape." "alt": "Two zebras walking across a black and white landscape."
}, },
{
"file": "web-2.jpg",
"alt": "Coastal town and hillside beside a blue sea under a bright sky."
},
{
"file": "web-3-2.jpg",
"alt": "A woman walking away on a beach while carrying a bag."
},
{ {
"file": "web-3.jpg", "file": "web-3.jpg",
"alt": "Black and white lake scene with a small boat under a bright sun." "alt": "Black and white lake scene with a small boat under a bright sun."
@ -103,14 +103,6 @@
"file": "web-11.jpg", "file": "web-11.jpg",
"alt": "Black and white close-up portrait of a dog looking into the camera." "alt": "Black and white close-up portrait of a dog looking into the camera."
}, },
{
"file": "web-12.jpg",
"alt": "Two people sitting by water under a vivid orange sunset."
},
{
"file": "web-13.jpg",
"alt": "Portrait of a woman standing beside a tree in green summer light."
},
{ {
"file": "web-14.jpg", "file": "web-14.jpg",
"alt": "Golden sunlight reflecting on rippled water." "alt": "Golden sunlight reflecting on rippled water."
@ -127,38 +119,14 @@
"file": "web-19.jpg", "file": "web-19.jpg",
"alt": "A bridge at night with blue lights and long yellow light trails." "alt": "A bridge at night with blue lights and long yellow light trails."
}, },
{
"file": "web-20.jpg",
"alt": "A seated man in a dark coat photographed at night with city lights behind him."
},
{
"file": "web-22.jpg",
"alt": "Silhouettes seen through large windows during a pink and blue sunset."
},
{ {
"file": "web-23.jpg", "file": "web-23.jpg",
"alt": "Tall clock tower and stone architecture with a bird flying nearby." "alt": "Tall clock tower and stone architecture with a bird flying nearby."
}, },
{
"file": "web-24.jpg",
"alt": "Portrait of a woman at night with bridge lights behind her."
},
{
"file": "web-25.jpg",
"alt": "Portrait of a smiling man at night with blurred city lights behind him."
},
{ {
"file": "web-26.jpg", "file": "web-26.jpg",
"alt": "Tall bridge lamps receding into a pale sky." "alt": "Tall bridge lamps receding into a pale sky."
}, },
{
"file": "web-42.jpg",
"alt": "Warm sunset over a grassy field with low hills in the distance."
},
{
"file": "_DSC2636-sterling.jpg",
"alt": "A leaf-strewn forest path winding between autumn trees into misty green woodland under soft grey light."
},
{ {
"file": "_DSC2675-sterling.jpg", "file": "_DSC2675-sterling.jpg",
"alt": "Tall conifer trees rising into pale morning mist beneath a soft grey sky." "alt": "Tall conifer trees rising into pale morning mist beneath a soft grey sky."
@ -176,8 +144,8 @@
"alt": "Sunlit dirt path winding through silhouetted pine trees in soft black-and-white woodland light." "alt": "Sunlit dirt path winding through silhouetted pine trees in soft black-and-white woodland light."
}, },
{ {
"file": "20230703_211420_C19E401F.jpg", "file": "20230703_211414_91173C51.jpg",
"alt": "Silhouetted pine trees in a misty forest with soft light filtering through their leaning trunks." "alt": "A wire-clamp glass jar holding dark leaves in water rests on a windowsill, a rain-speckled window and blurred city beyond in soft black-and-white light."
}, },
{ {
"file": "IMG_3907.JPG", "file": "IMG_3907.JPG",
@ -207,12 +175,16 @@
"file": "colour-25.jpg", "file": "colour-25.jpg",
"alt": "A red road-closed sign and yellow barriers before a bright orange perforated building facade under grey sky." "alt": "A red road-closed sign and yellow barriers before a bright orange perforated building facade under grey sky."
}, },
{
"file": "colour-40.jpg",
"alt": "Aerial view over a hazy city with railway lines stretching toward the horizon beside a river."
},
{ {
"file": "colour-41.jpg", "file": "colour-41.jpg",
"alt": "Aerial view of a hazy city skyline at sunset, with golden light rays piercing through dark clouds." "alt": "Aerial view of a hazy city skyline at sunset, with golden light rays piercing through dark clouds."
},
{
"file": "colour-43.jpg",
"alt": "A giant floating globe sculpture of Earth rests on water among glass office towers, with a light railway train crossing a bridge under a grey sky."
},
{
"file": "NYC-2025-185.jpg",
"alt": "A pale straw cowboy hat hanging on a bare branch against a soft, blurred wintry background."
} }
] ]

View file

@ -109,4 +109,67 @@ describe('PhotoGallery', () => {
expect(frame.querySelector('img')?.alt).toBe('Second photo'); expect(frame.querySelector('img')?.alt).toBe('Second photo');
}); });
it('preloads the next photo off-screen without duplicating the frame id', () => {
const { gallery, frame, toggle } = setup();
new PhotoGallery({ gallery, frame, toggle, autoAdvance: false });
const preload = document.querySelector('.frame-preload img');
expect(preload?.getAttribute('src')).toBe('static/photos/two-2400.jpg');
// The off-screen copy must not steal the displayed image's id.
expect(preload?.id).toBe('');
expect(document.querySelectorAll('#frame-image')).toHaveLength(0);
});
it('moves the preload to the next photo as the selection advances', () => {
const { gallery, frame, toggle } = setup();
new PhotoGallery({ gallery, frame, toggle, autoAdvance: false });
gallery
.querySelector<HTMLAnchorElement>('[data-photo-id="two"]')
?.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true })
);
// Showing 'two' leaves exactly one framed image and preloads the wrap-around.
expect(document.querySelectorAll('#frame-image')).toHaveLength(1);
expect(
document.querySelector('.frame-preload img')?.getAttribute('src')
).toBe('static/photos/one-2400.jpg');
});
it('sets the aspect ratio so the frame reserves the photo size before it loads', () => {
const { gallery, frame, toggle } = setup();
new PhotoGallery({ gallery, frame, toggle, autoAdvance: false });
gallery
.querySelector<HTMLAnchorElement>('[data-photo-id="one"]')
?.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true })
);
// Thumbnail markup is 320x240, so the figure carries that ratio for CSS.
const figure = frame.querySelector<HTMLElement>('.frame-figure');
expect(
parseFloat(figure?.style.getPropertyValue('--frame-ratio') ?? '')
).toBeCloseTo(320 / 240, 4);
});
it('shows a loading animation for a user load and clears it when ready', () => {
const { gallery, frame, toggle } = setup();
new PhotoGallery({ gallery, frame, toggle, autoAdvance: false });
// 'one' is not the preloaded photo, so clicking it builds a fresh figure
// whose image is still loading.
gallery
.querySelector<HTMLAnchorElement>('[data-photo-id="one"]')
?.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true })
);
expect(frame.classList.contains('is-loading')).toBe(true);
frame.querySelector('img')?.dispatchEvent(new Event('load'));
expect(frame.classList.contains('is-loading')).toBe(false);
});
}); });

View file

@ -63,13 +63,21 @@ const createFrameFigure = (thumbnail: HTMLAnchorElement): HTMLElement => {
element.setAttribute('sizes', sizes); element.setAttribute('sizes', sizes);
} }
image.id = 'frame-image'; // The `frame-image` id is assigned by showFigure when the figure is placed in
// the frame, so a figure waiting in the preload holder never duplicates it.
if (thumbnail.dataset.frameSrc) { if (thumbnail.dataset.frameSrc) {
image.src = thumbnail.dataset.frameSrc; image.src = thumbnail.dataset.frameSrc;
} }
image.removeAttribute('loading'); image.removeAttribute('loading');
image.decoding = 'async'; image.decoding = 'async';
// Reserve the photo's final size before it loads (see --frame-ratio in CSS).
const ratio =
Number(image.getAttribute('width')) / Number(image.getAttribute('height'));
if (Number.isFinite(ratio) && ratio > 0) {
figure.style.setProperty('--frame-ratio', String(ratio));
}
figure.append(picture); figure.append(picture);
return figure; return figure;
@ -78,7 +86,9 @@ const createFrameFigure = (thumbnail: HTMLAnchorElement): HTMLElement => {
export class PhotoGallery { export class PhotoGallery {
private selectedIndex = 0; private selectedIndex = 0;
private timerId: number | null = null; private timerId: number | null = null;
private preloaded: { index: number; figure: HTMLElement } | null = null;
private readonly thumbnails: HTMLAnchorElement[]; private readonly thumbnails: HTMLAnchorElement[];
private readonly preloadHolder = document.createElement('div');
private readonly reducedMotion = window.matchMedia( private readonly reducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)' '(prefers-reduced-motion: reduce)'
); );
@ -92,11 +102,18 @@ export class PhotoGallery {
throw new Error('The gallery needs at least one photo.'); throw new Error('The gallery needs at least one photo.');
} }
// Off-screen holder that warms the next photo's image so advancing to it is
// instant; see preloadNext.
this.preloadHolder.className = 'frame-preload';
this.preloadHolder.setAttribute('aria-hidden', 'true');
document.body.append(this.preloadHolder);
this.addEventListeners(); this.addEventListeners();
// The frame for photo 0 is already server-rendered, so just mark the // The frame for photo 0 is already server-rendered, so just mark the
// selected thumbnail; rebuilding it would discard the in-flight LCP image. // selected thumbnail; rebuilding it would discard the in-flight LCP image.
this.updateSelectedThumbnail(0, false); this.updateSelectedThumbnail(0, false);
this.preloadNext();
if (this.options.autoAdvance !== false && !this.reducedMotion.matches) { if (this.options.autoAdvance !== false && !this.reducedMotion.matches) {
this.startAutoAdvance(); this.startAutoAdvance();
@ -201,7 +218,11 @@ export class PhotoGallery {
throw new Error('Selected photo is missing.'); throw new Error('Selected photo is missing.');
} }
this.options.frame.replaceChildren(createFrameFigure(thumbnail)); // Auto-advance shows the preloaded figure instantly; a user-initiated jump
// (which carries a source) may need a download, so it gets the spinner.
this.showFigure(this.takeFigure(this.selectedIndex, thumbnail), {
loading: options.source !== undefined,
});
this.updateSelectedThumbnail( this.updateSelectedThumbnail(
this.selectedIndex, this.selectedIndex,
options.focusThumbnail ?? false options.focusThumbnail ?? false
@ -219,6 +240,63 @@ export class PhotoGallery {
source: options.source, source: options.source,
}); });
} }
this.preloadNext();
}
// Reuse the figure preloaded for this index when we have it — the browser has
// already fetched its image — otherwise build a fresh one.
private takeFigure(index: number, thumbnail: HTMLAnchorElement): HTMLElement {
if (this.preloaded?.index === index) {
const { figure } = this.preloaded;
this.preloaded = null;
return figure;
}
return createFrameFigure(thumbnail);
}
private showFigure(figure: HTMLElement, options: { loading: boolean }): void {
const image = figure.querySelector('img');
if (image) {
image.id = 'frame-image';
}
this.options.frame.replaceChildren(figure);
// Show a spinner only while a not-yet-loaded image arrives; a preloaded
// (already complete) image is displayed immediately, with no flash.
if (options.loading && image && !image.complete) {
this.options.frame.classList.add('is-loading');
const clear = (): void =>
this.options.frame.classList.remove('is-loading');
image.addEventListener('load', clear, { once: true });
image.addEventListener('error', clear, { once: true });
} else {
this.options.frame.classList.remove('is-loading');
}
}
// Build the photo the slideshow will advance to next and park it off-screen
// so the browser downloads it before we need to show it.
private preloadNext(): void {
if (this.thumbnails.length <= 1) {
return;
}
const nextIndex = wrapIndex(this.selectedIndex + 1, this.thumbnails.length);
if (this.preloaded?.index === nextIndex) {
return;
}
const thumbnail = this.thumbnails[nextIndex];
if (!thumbnail) {
return;
}
const figure = createFrameFigure(thumbnail);
this.preloaded = { index: nextIndex, figure };
this.preloadHolder.replaceChildren(figure);
} }
private updateSelectedThumbnail(index: number, shouldFocus: boolean): void { private updateSelectedThumbnail(index: number, shouldFocus: boolean): void {

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

BIN
src/pictures/colour-43.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 KiB