diff --git a/e2e/gallery.spec.ts b/e2e/gallery.spec.ts index 9c9f715..eab422a 100644 --- a/e2e/gallery.spec.ts +++ b/e2e/gallery.spec.ts @@ -5,7 +5,7 @@ test('renders the gallery and supports keyboard navigation', async ({ }) => { await page.goto('/'); - await expect(page.locator('.thumbnail')).toHaveCount(54); + await expect(page.locator('.thumbnail')).toHaveCount(47); await expect(page.locator('#frame-image')).toBeVisible(); const firstAlt = await page.locator('#frame-image').getAttribute('alt'); diff --git a/scripts/generate-assets.mjs b/scripts/generate-assets.mjs index a0ca84d..798c20b 100644 --- a/scripts/generate-assets.mjs +++ b/scripts/generate-assets.mjs @@ -116,7 +116,9 @@ const renderThumbnail = (photo, isCurrent) => { `; }; -const renderFrame = (photo) => `
+const renderFrame = ( + photo +) => `
${renderPicture( photo, frameSizes(photo), diff --git a/src/index.scss b/src/index.scss index 18a1066..a2fb232 100644 --- a/src/index.scss +++ b/src/index.scss @@ -144,25 +144,67 @@ img { min-width: 0; min-height: 0; 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 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 { - min-width: 0; margin: 0; - display: grid; - justify-items: center; + inline-size: min(100%, calc((100dvh - 5rem) * var(--frame-ratio, 1))); } .frame-figure picture { - display: grid; - place-items: center; - min-width: 0; + display: block; } .frame-figure img { - inline-size: auto; - max-inline-size: 100%; + inline-size: 100%; + block-size: auto; max-block-size: calc(100dvh - 5rem); border: var(--border-width) solid var(--color-text); background: var(--color-text); @@ -174,8 +216,7 @@ img { display: none; position: absolute; inset-block-start: var(--space-s); - inset-inline-start: var(--space-s); - justify-self: start; + justify-self: center; border: 0; border-radius: 4px; padding: 0.45rem 0.75rem; @@ -206,7 +247,9 @@ img { } .slideshow-toggle { + inset-block-start: 50%; inset-inline: auto var(--space-s); + transform: translateY(-50%); } .site-header h1 { @@ -257,6 +300,10 @@ img { padding: var(--space-s); } + .frame-figure { + inline-size: min(100%, calc((100dvh - 12rem) * var(--frame-ratio, 1))); + } + .frame-figure img { max-block-size: calc(100dvh - 12rem); } diff --git a/src/photo-catalog.json b/src/photo-catalog.json index c2f5cd9..33d2fc5 100644 --- a/src/photo-catalog.json +++ b/src/photo-catalog.json @@ -1,24 +1,44 @@ [ { - "file": "_DSC1190-Edit.jpg", - "alt": "Portrait of a man in a burgundy sleeveless shirt standing with folded arms above a city view." + "file": "_DSC2821-iceland.jpg", + "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", - "alt": "A man smiling while drying a plate in a kitchen." + "file": "_DSC2879-iceland.jpg", + "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", "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", "alt": "A person in a hooded jacket walking down a narrow city street." @@ -27,14 +47,6 @@ "file": "_DSC8718-Edit.jpg", "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", "alt": "A small plant stem silhouetted against warm yellow and orange light." @@ -51,10 +63,6 @@ "file": "nyomtatas-10.jpg", "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", "alt": "Black and white stage scene with dancers performing before an audience." @@ -63,14 +71,6 @@ "file": "web-2-2.jpg", "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", "alt": "Black and white lake scene with a small boat under a bright sun." @@ -103,14 +103,6 @@ "file": "web-11.jpg", "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", "alt": "Golden sunlight reflecting on rippled water." @@ -127,38 +119,14 @@ "file": "web-19.jpg", "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", "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", "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", "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." }, { - "file": "20230703_211420_C19E401F.jpg", - "alt": "Silhouetted pine trees in a misty forest with soft light filtering through their leaning trunks." + "file": "20230703_211414_91173C51.jpg", + "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", @@ -207,12 +175,16 @@ "file": "colour-25.jpg", "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", "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." } ] diff --git a/src/photos.test.ts b/src/photos.test.ts index febfe97..5056ef0 100644 --- a/src/photos.test.ts +++ b/src/photos.test.ts @@ -109,4 +109,67 @@ describe('PhotoGallery', () => { 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('[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('[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('.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('[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); + }); }); diff --git a/src/photos.ts b/src/photos.ts index c7c3115..dcf31c7 100644 --- a/src/photos.ts +++ b/src/photos.ts @@ -63,13 +63,21 @@ const createFrameFigure = (thumbnail: HTMLAnchorElement): HTMLElement => { 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) { image.src = thumbnail.dataset.frameSrc; } image.removeAttribute('loading'); 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); return figure; @@ -78,7 +86,9 @@ const createFrameFigure = (thumbnail: HTMLAnchorElement): HTMLElement => { export class PhotoGallery { private selectedIndex = 0; private timerId: number | null = null; + private preloaded: { index: number; figure: HTMLElement } | null = null; private readonly thumbnails: HTMLAnchorElement[]; + private readonly preloadHolder = document.createElement('div'); private readonly reducedMotion = window.matchMedia( '(prefers-reduced-motion: reduce)' ); @@ -92,11 +102,18 @@ export class PhotoGallery { 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(); // The frame for photo 0 is already server-rendered, so just mark the // selected thumbnail; rebuilding it would discard the in-flight LCP image. this.updateSelectedThumbnail(0, false); + this.preloadNext(); if (this.options.autoAdvance !== false && !this.reducedMotion.matches) { this.startAutoAdvance(); @@ -201,7 +218,11 @@ export class PhotoGallery { 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.selectedIndex, options.focusThumbnail ?? false @@ -219,6 +240,63 @@ export class PhotoGallery { 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 { diff --git a/src/pictures/20230703_211414_91173C51.jpg b/src/pictures/20230703_211414_91173C51.jpg new file mode 100644 index 0000000..a80dae2 Binary files /dev/null and b/src/pictures/20230703_211414_91173C51.jpg differ diff --git a/src/pictures/20230703_211420_C19E401F.jpg b/src/pictures/20230703_211420_C19E401F.jpg deleted file mode 100644 index 3df0c02..0000000 Binary files a/src/pictures/20230703_211420_C19E401F.jpg and /dev/null differ diff --git a/src/pictures/2025-NYC-175.jpg b/src/pictures/2025-NYC-175.jpg new file mode 100644 index 0000000..56bcad8 Binary files /dev/null and b/src/pictures/2025-NYC-175.jpg differ diff --git a/src/pictures/2025-NYC-194.jpg b/src/pictures/2025-NYC-194.jpg new file mode 100644 index 0000000..8ca1ec4 Binary files /dev/null and b/src/pictures/2025-NYC-194.jpg differ diff --git a/src/pictures/2025-NYC-232.jpg b/src/pictures/2025-NYC-232.jpg new file mode 100644 index 0000000..4bdd710 Binary files /dev/null and b/src/pictures/2025-NYC-232.jpg differ diff --git a/src/pictures/2025-NYC-31.jpg b/src/pictures/2025-NYC-31.jpg new file mode 100644 index 0000000..f82db18 Binary files /dev/null and b/src/pictures/2025-NYC-31.jpg differ diff --git a/src/pictures/2025-NYC-41.jpg b/src/pictures/2025-NYC-41.jpg new file mode 100644 index 0000000..b0ff1c9 Binary files /dev/null and b/src/pictures/2025-NYC-41.jpg differ diff --git a/src/pictures/NYC-2025-185.jpg b/src/pictures/NYC-2025-185.jpg new file mode 100644 index 0000000..b1a1196 Binary files /dev/null and b/src/pictures/NYC-2025-185.jpg differ diff --git a/src/pictures/_DSC1190-Edit.jpg b/src/pictures/_DSC1190-Edit.jpg deleted file mode 100644 index df2fd4c..0000000 Binary files a/src/pictures/_DSC1190-Edit.jpg and /dev/null differ diff --git a/src/pictures/_DSC1484.jpg b/src/pictures/_DSC1484.jpg deleted file mode 100644 index 6bd8104..0000000 Binary files a/src/pictures/_DSC1484.jpg and /dev/null differ diff --git a/src/pictures/_DSC2313.jpg b/src/pictures/_DSC2313.jpg deleted file mode 100644 index 0f47f90..0000000 Binary files a/src/pictures/_DSC2313.jpg and /dev/null differ diff --git a/src/pictures/_DSC2362.jpg b/src/pictures/_DSC2362.jpg deleted file mode 100644 index 9705b7d..0000000 Binary files a/src/pictures/_DSC2362.jpg and /dev/null differ diff --git a/src/pictures/_DSC2636-sterling.jpg b/src/pictures/_DSC2636-sterling.jpg deleted file mode 100644 index 67dbba8..0000000 Binary files a/src/pictures/_DSC2636-sterling.jpg and /dev/null differ diff --git a/src/pictures/_DSC2821-iceland.jpg b/src/pictures/_DSC2821-iceland.jpg new file mode 100644 index 0000000..bab42cd Binary files /dev/null and b/src/pictures/_DSC2821-iceland.jpg differ diff --git a/src/pictures/_DSC2879-iceland.jpg b/src/pictures/_DSC2879-iceland.jpg new file mode 100644 index 0000000..d356c3c Binary files /dev/null and b/src/pictures/_DSC2879-iceland.jpg differ diff --git a/src/pictures/_DSC2975-iceland.jpg b/src/pictures/_DSC2975-iceland.jpg new file mode 100644 index 0000000..968b5cd Binary files /dev/null and b/src/pictures/_DSC2975-iceland.jpg differ diff --git a/src/pictures/_DSC3105-iceland.jpg b/src/pictures/_DSC3105-iceland.jpg new file mode 100644 index 0000000..c51abe4 Binary files /dev/null and b/src/pictures/_DSC3105-iceland.jpg differ diff --git a/src/pictures/adam.jpg b/src/pictures/adam.jpg deleted file mode 100644 index 73d9ac3..0000000 Binary files a/src/pictures/adam.jpg and /dev/null differ diff --git a/src/pictures/colour-40.jpg b/src/pictures/colour-40.jpg deleted file mode 100644 index b0b7e73..0000000 Binary files a/src/pictures/colour-40.jpg and /dev/null differ diff --git a/src/pictures/colour-43.jpg b/src/pictures/colour-43.jpg new file mode 100644 index 0000000..1ee7bb8 Binary files /dev/null and b/src/pictures/colour-43.jpg differ diff --git a/src/pictures/dorka-1.jpg b/src/pictures/dorka-1.jpg deleted file mode 100644 index ef02359..0000000 Binary files a/src/pictures/dorka-1.jpg and /dev/null differ diff --git a/src/pictures/web-1-2.jpg b/src/pictures/web-1-2.jpg deleted file mode 100644 index ade2db9..0000000 Binary files a/src/pictures/web-1-2.jpg and /dev/null differ diff --git a/src/pictures/web-12.jpg b/src/pictures/web-12.jpg deleted file mode 100644 index 77aba0f..0000000 Binary files a/src/pictures/web-12.jpg and /dev/null differ diff --git a/src/pictures/web-13.jpg b/src/pictures/web-13.jpg deleted file mode 100644 index 4ae2677..0000000 Binary files a/src/pictures/web-13.jpg and /dev/null differ diff --git a/src/pictures/web-2.jpg b/src/pictures/web-2.jpg deleted file mode 100644 index 6678aa8..0000000 Binary files a/src/pictures/web-2.jpg and /dev/null differ diff --git a/src/pictures/web-20.jpg b/src/pictures/web-20.jpg deleted file mode 100644 index 4e78f3d..0000000 Binary files a/src/pictures/web-20.jpg and /dev/null differ diff --git a/src/pictures/web-22.jpg b/src/pictures/web-22.jpg deleted file mode 100644 index 1ca7221..0000000 Binary files a/src/pictures/web-22.jpg and /dev/null differ diff --git a/src/pictures/web-24.jpg b/src/pictures/web-24.jpg deleted file mode 100644 index 2b4ca8f..0000000 Binary files a/src/pictures/web-24.jpg and /dev/null differ diff --git a/src/pictures/web-25.jpg b/src/pictures/web-25.jpg deleted file mode 100644 index 4d0c472..0000000 Binary files a/src/pictures/web-25.jpg and /dev/null differ diff --git a/src/pictures/web-3-2.jpg b/src/pictures/web-3-2.jpg deleted file mode 100644 index 3762e58..0000000 Binary files a/src/pictures/web-3-2.jpg and /dev/null differ diff --git a/src/pictures/web-42.jpg b/src/pictures/web-42.jpg deleted file mode 100644 index 8ddc278..0000000 Binary files a/src/pictures/web-42.jpg and /dev/null differ