diff --git a/e2e/gallery.spec.ts b/e2e/gallery.spec.ts index eab422a..9c9f715 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(47); + await expect(page.locator('.thumbnail')).toHaveCount(54); 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 798c20b..a0ca84d 100644 --- a/scripts/generate-assets.mjs +++ b/scripts/generate-assets.mjs @@ -116,9 +116,7 @@ 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 a2fb232..18a1066 100644 --- a/src/index.scss +++ b/src/index.scss @@ -144,67 +144,25 @@ img { min-width: 0; min-height: 0; display: grid; - - // 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; + place-items: center; } -.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; - inline-size: min(100%, calc((100dvh - 5rem) * var(--frame-ratio, 1))); + display: grid; + justify-items: center; } .frame-figure picture { - display: block; + display: grid; + place-items: center; + min-width: 0; } .frame-figure img { - inline-size: 100%; - block-size: auto; + inline-size: auto; + max-inline-size: 100%; max-block-size: calc(100dvh - 5rem); border: var(--border-width) solid var(--color-text); background: var(--color-text); @@ -216,7 +174,8 @@ img { display: none; position: absolute; inset-block-start: var(--space-s); - justify-self: center; + inset-inline-start: var(--space-s); + justify-self: start; border: 0; border-radius: 4px; padding: 0.45rem 0.75rem; @@ -247,9 +206,7 @@ img { } .slideshow-toggle { - inset-block-start: 50%; inset-inline: auto var(--space-s); - transform: translateY(-50%); } .site-header h1 { @@ -300,10 +257,6 @@ 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 33d2fc5..c2f5cd9 100644 --- a/src/photo-catalog.json +++ b/src/photo-catalog.json @@ -1,44 +1,24 @@ [ { - "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": "_DSC1190-Edit.jpg", + "alt": "Portrait of a man in a burgundy sleeveless shirt standing with folded arms above a city view." }, { - "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": "_DSC1484.jpg", + "alt": "A man smiling while drying a plate in a kitchen." }, { "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." @@ -47,6 +27,14 @@ "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." @@ -63,6 +51,10 @@ "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." @@ -71,6 +63,14 @@ "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,6 +103,14 @@ "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." @@ -119,14 +127,38 @@ "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." @@ -144,8 +176,8 @@ "alt": "Sunlit dirt path winding through silhouetted pine trees in soft black-and-white woodland light." }, { - "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": "20230703_211420_C19E401F.jpg", + "alt": "Silhouetted pine trees in a misty forest with soft light filtering through their leaning trunks." }, { "file": "IMG_3907.JPG", @@ -175,16 +207,12 @@ "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 5056ef0..febfe97 100644 --- a/src/photos.test.ts +++ b/src/photos.test.ts @@ -109,67 +109,4 @@ 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 dcf31c7..c7c3115 100644 --- a/src/photos.ts +++ b/src/photos.ts @@ -63,21 +63,13 @@ const createFrameFigure = (thumbnail: HTMLAnchorElement): HTMLElement => { element.setAttribute('sizes', sizes); } - // 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. + image.id = 'frame-image'; 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; @@ -86,9 +78,7 @@ 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)' ); @@ -102,18 +92,11 @@ 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(); @@ -218,11 +201,7 @@ export class PhotoGallery { throw new Error('Selected photo is missing.'); } - // 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.options.frame.replaceChildren(createFrameFigure(thumbnail)); this.updateSelectedThumbnail( this.selectedIndex, options.focusThumbnail ?? false @@ -240,63 +219,6 @@ 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 deleted file mode 100644 index a80dae2..0000000 Binary files a/src/pictures/20230703_211414_91173C51.jpg and /dev/null differ diff --git a/src/pictures/20230703_211420_C19E401F.jpg b/src/pictures/20230703_211420_C19E401F.jpg new file mode 100644 index 0000000..3df0c02 Binary files /dev/null and b/src/pictures/20230703_211420_C19E401F.jpg differ diff --git a/src/pictures/2025-NYC-175.jpg b/src/pictures/2025-NYC-175.jpg deleted file mode 100644 index 56bcad8..0000000 Binary files a/src/pictures/2025-NYC-175.jpg and /dev/null differ diff --git a/src/pictures/2025-NYC-194.jpg b/src/pictures/2025-NYC-194.jpg deleted file mode 100644 index 8ca1ec4..0000000 Binary files a/src/pictures/2025-NYC-194.jpg and /dev/null differ diff --git a/src/pictures/2025-NYC-232.jpg b/src/pictures/2025-NYC-232.jpg deleted file mode 100644 index 4bdd710..0000000 Binary files a/src/pictures/2025-NYC-232.jpg and /dev/null differ diff --git a/src/pictures/2025-NYC-31.jpg b/src/pictures/2025-NYC-31.jpg deleted file mode 100644 index f82db18..0000000 Binary files a/src/pictures/2025-NYC-31.jpg and /dev/null differ diff --git a/src/pictures/2025-NYC-41.jpg b/src/pictures/2025-NYC-41.jpg deleted file mode 100644 index b0ff1c9..0000000 Binary files a/src/pictures/2025-NYC-41.jpg and /dev/null differ diff --git a/src/pictures/NYC-2025-185.jpg b/src/pictures/NYC-2025-185.jpg deleted file mode 100644 index b1a1196..0000000 Binary files a/src/pictures/NYC-2025-185.jpg and /dev/null differ diff --git a/src/pictures/_DSC1190-Edit.jpg b/src/pictures/_DSC1190-Edit.jpg new file mode 100644 index 0000000..df2fd4c Binary files /dev/null and b/src/pictures/_DSC1190-Edit.jpg differ diff --git a/src/pictures/_DSC1484.jpg b/src/pictures/_DSC1484.jpg new file mode 100644 index 0000000..6bd8104 Binary files /dev/null and b/src/pictures/_DSC1484.jpg differ diff --git a/src/pictures/_DSC2313.jpg b/src/pictures/_DSC2313.jpg new file mode 100644 index 0000000..0f47f90 Binary files /dev/null and b/src/pictures/_DSC2313.jpg differ diff --git a/src/pictures/_DSC2362.jpg b/src/pictures/_DSC2362.jpg new file mode 100644 index 0000000..9705b7d Binary files /dev/null and b/src/pictures/_DSC2362.jpg differ diff --git a/src/pictures/_DSC2636-sterling.jpg b/src/pictures/_DSC2636-sterling.jpg new file mode 100644 index 0000000..67dbba8 Binary files /dev/null and b/src/pictures/_DSC2636-sterling.jpg differ diff --git a/src/pictures/_DSC2821-iceland.jpg b/src/pictures/_DSC2821-iceland.jpg deleted file mode 100644 index bab42cd..0000000 Binary files a/src/pictures/_DSC2821-iceland.jpg and /dev/null differ diff --git a/src/pictures/_DSC2879-iceland.jpg b/src/pictures/_DSC2879-iceland.jpg deleted file mode 100644 index d356c3c..0000000 Binary files a/src/pictures/_DSC2879-iceland.jpg and /dev/null differ diff --git a/src/pictures/_DSC2975-iceland.jpg b/src/pictures/_DSC2975-iceland.jpg deleted file mode 100644 index 968b5cd..0000000 Binary files a/src/pictures/_DSC2975-iceland.jpg and /dev/null differ diff --git a/src/pictures/_DSC3105-iceland.jpg b/src/pictures/_DSC3105-iceland.jpg deleted file mode 100644 index c51abe4..0000000 Binary files a/src/pictures/_DSC3105-iceland.jpg and /dev/null differ diff --git a/src/pictures/adam.jpg b/src/pictures/adam.jpg new file mode 100644 index 0000000..73d9ac3 Binary files /dev/null and b/src/pictures/adam.jpg differ diff --git a/src/pictures/colour-40.jpg b/src/pictures/colour-40.jpg new file mode 100644 index 0000000..b0b7e73 Binary files /dev/null and b/src/pictures/colour-40.jpg differ diff --git a/src/pictures/colour-43.jpg b/src/pictures/colour-43.jpg deleted file mode 100644 index 1ee7bb8..0000000 Binary files a/src/pictures/colour-43.jpg and /dev/null differ diff --git a/src/pictures/dorka-1.jpg b/src/pictures/dorka-1.jpg new file mode 100644 index 0000000..ef02359 Binary files /dev/null and b/src/pictures/dorka-1.jpg differ diff --git a/src/pictures/web-1-2.jpg b/src/pictures/web-1-2.jpg new file mode 100644 index 0000000..ade2db9 Binary files /dev/null and b/src/pictures/web-1-2.jpg differ diff --git a/src/pictures/web-12.jpg b/src/pictures/web-12.jpg new file mode 100644 index 0000000..77aba0f Binary files /dev/null and b/src/pictures/web-12.jpg differ diff --git a/src/pictures/web-13.jpg b/src/pictures/web-13.jpg new file mode 100644 index 0000000..4ae2677 Binary files /dev/null and b/src/pictures/web-13.jpg differ diff --git a/src/pictures/web-2.jpg b/src/pictures/web-2.jpg new file mode 100644 index 0000000..6678aa8 Binary files /dev/null and b/src/pictures/web-2.jpg differ diff --git a/src/pictures/web-20.jpg b/src/pictures/web-20.jpg new file mode 100644 index 0000000..4e78f3d Binary files /dev/null and b/src/pictures/web-20.jpg differ diff --git a/src/pictures/web-22.jpg b/src/pictures/web-22.jpg new file mode 100644 index 0000000..1ca7221 Binary files /dev/null and b/src/pictures/web-22.jpg differ diff --git a/src/pictures/web-24.jpg b/src/pictures/web-24.jpg new file mode 100644 index 0000000..2b4ca8f Binary files /dev/null and b/src/pictures/web-24.jpg differ diff --git a/src/pictures/web-25.jpg b/src/pictures/web-25.jpg new file mode 100644 index 0000000..4d0c472 Binary files /dev/null and b/src/pictures/web-25.jpg differ diff --git a/src/pictures/web-3-2.jpg b/src/pictures/web-3-2.jpg new file mode 100644 index 0000000..3762e58 Binary files /dev/null and b/src/pictures/web-3-2.jpg differ diff --git a/src/pictures/web-42.jpg b/src/pictures/web-42.jpg new file mode 100644 index 0000000..8ddc278 Binary files /dev/null and b/src/pictures/web-42.jpg differ