From 8f358c2e0dcaf4ef7fe3f64687627ca2b8637ce8 Mon Sep 17 00:00:00 2001 From: LucaP Date: Fri, 4 Sep 2026 21:48:51 +0300 Subject: [PATCH] fix: contain project media layouts --- src/components/ProjectMediaGallery.vue | 12 ++-- src/styles/components.css | 39 +++++++++-- src/styles/print.css | 4 ++ tests/browser/responsive-layout.spec.ts | 86 ++++++++++++++++++++----- tests/unit/ProjectMedia.test.ts | 1 + 5 files changed, 117 insertions(+), 25 deletions(-) diff --git a/src/components/ProjectMediaGallery.vue b/src/components/ProjectMediaGallery.vue index d83ff5f..19f2b81 100644 --- a/src/components/ProjectMediaGallery.vue +++ b/src/components/ProjectMediaGallery.vue @@ -11,11 +11,15 @@ withDefaults(defineProps<{ diff --git a/src/styles/components.css b/src/styles/components.css index 91148f3..3a4b93f 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -682,13 +682,14 @@ overflow: visible; } +.project-media-gallery__item, .project-media-gallery .project-media { position: relative; min-inline-size: 0; overflow: visible; } -.project-media-gallery .project-media:focus-within { +.project-media-gallery__item:focus-within { z-index: 1; } @@ -704,15 +705,15 @@ padding-inline: 0.5rem; } - .project-media-gallery[data-media-layout="portrait-strip"] > .project-media + .project-media { + .project-media-gallery[data-media-layout="portrait-strip"] > .project-media-gallery__item + .project-media-gallery__item { margin-inline-start: -0.75rem; } - .project-media-gallery[data-media-layout="portrait-strip"] > .project-media:nth-child(even) { + .project-media-gallery[data-media-layout="portrait-strip"] > .project-media-gallery__item:nth-child(even) { margin-block-start: 1rem; } - .project-media-gallery[data-media-layout="portrait-strip"] > .project-media:nth-child(3n) { + .project-media-gallery[data-media-layout="portrait-strip"] > .project-media-gallery__item:nth-child(3n) { margin-block-start: 0.5rem; } @@ -720,6 +721,32 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; } + + .project-media-gallery[data-media-layout="chart-pair"] > .project-media-gallery__item > .project-media { + display: grid; + grid-template-rows: clamp(12rem, 24vw, 22rem) auto; + position: relative; + align-content: start; + } + + .project-media-gallery[data-media-layout="chart-pair"] > .project-media-gallery__item > .project-media > :is(img, video) { + position: absolute; + inset: 0 0 auto; + inline-size: 100% !important; + max-inline-size: 100% !important; + block-size: clamp(12rem, 24vw, 22rem) !important; + max-block-size: clamp(12rem, 24vw, 22rem) !important; + object-fit: contain; + object-position: center; + } + + .project-media-gallery[data-media-layout="chart-pair"] > .project-media-gallery__item > .project-media > figcaption { + grid-row: 2; + } + + .project-media-gallery[data-media-layout="chart-pair"] > .project-media-gallery__item > .project-media > :not(:is(img, video, figcaption)) { + grid-row: 3; + } } .project-media img, @@ -894,8 +921,8 @@ padding-inline: 0; } - .project-media-gallery[data-media-layout="portrait-strip"] > .project-media, - .project-media-gallery[data-media-layout="portrait-strip"] > .project-media + .project-media { + .project-media-gallery[data-media-layout="portrait-strip"] > .project-media-gallery__item, + .project-media-gallery[data-media-layout="portrait-strip"] > .project-media-gallery__item + .project-media-gallery__item { margin-block-start: 0; margin-inline-start: 0; } diff --git a/src/styles/print.css b/src/styles/print.css index 2fe7849..63cdda6 100644 --- a/src/styles/print.css +++ b/src/styles/print.css @@ -117,7 +117,11 @@ .project-media-gallery img, .project-media-gallery video { max-inline-size: 100%; + max-block-size: min(9in, calc(100vh - 6rem)); + inline-size: auto; block-size: auto; + margin-inline: auto; + object-fit: contain; } a { diff --git a/tests/browser/responsive-layout.spec.ts b/tests/browser/responsive-layout.spec.ts index ba1b5bd..29eae1f 100644 --- a/tests/browser/responsive-layout.spec.ts +++ b/tests/browser/responsive-layout.spec.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { expect, test, type Page, type TestInfo } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; import { createServer, type ViteDevServer } from 'vite'; const stylesheetFiles = [ @@ -33,6 +33,11 @@ const syntheticProjectMedia = [ { kind: 'image', assetId: 'synthetic-portrait-three', alt: 'Synthetic schedule evidence.', caption: 'Fictional demo schedule.' }, ] as const; +const syntheticChartPair = [ + { kind: 'image', assetId: 'synthetic-chart-wide', alt: 'Synthetic wide chart evidence.', caption: 'Fictional wide chart.' }, + { kind: 'image', assetId: 'synthetic-chart-tall', alt: 'Synthetic tall chart evidence.', caption: 'Fictional tall chart.' }, +] as const; + function withSyntheticProjectMedia(contentInput: unknown, manifestInput: unknown): { content: unknown; manifest: unknown } { const content = structuredClone(contentInput) as { projects: { selected: Array<{ assetIds: string[]; media?: unknown; mediaLayout?: unknown }> }; @@ -43,6 +48,10 @@ function withSyntheticProjectMedia(contentInput: unknown, manifestInput: unknown project.assetIds = syntheticProjectMedia.map((media) => media.assetId); project.media = syntheticProjectMedia; project.mediaLayout = 'portrait-strip'; + const chartPairProject = content.projects.selected[1]!; + chartPairProject.assetIds = syntheticChartPair.map((media) => media.assetId); + chartPairProject.media = syntheticChartPair; + chartPairProject.mediaLayout = 'chart-pair'; manifest.assets.push(...syntheticProjectMedia.map((media, index) => ({ id: media.assetId, fileName: `${media.assetId}.webp`, @@ -55,6 +64,32 @@ function withSyntheticProjectMedia(contentInput: unknown, manifestInput: unknown license: 'Test-only synthetic asset', attribution: 'Synthetic test fixture', }))); + manifest.assets.push( + { + id: syntheticChartPair[0].assetId, + fileName: `${syntheticChartPair[0].assetId}.webp`, + sha256: '4'.repeat(64), + mediaType: 'image/webp', + width: 1200, + height: 600, + durationMs: null, + rightsHolder: 'Synthetic test fixture', + license: 'Test-only synthetic asset', + attribution: 'Synthetic test fixture', + }, + { + id: syntheticChartPair[1].assetId, + fileName: `${syntheticChartPair[1].assetId}.webp`, + sha256: '5'.repeat(64), + mediaType: 'image/webp', + width: 600, + height: 1200, + durationMs: null, + rightsHolder: 'Synthetic test fixture', + license: 'Test-only synthetic asset', + attribution: 'Synthetic test fixture', + }, + ); return { content, manifest }; } @@ -98,10 +133,6 @@ async function loadInformationLayer(page: Page): Promise { await page.setContent(informationLayerHarness()); } -async function capture(page: Page, testInfo: TestInfo, name: string): Promise { - await page.screenshot({ path: testInfo.outputPath(name) }); -} - test('renders the validated eight-section portfolio structure before responsive checks', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await loadInformationLayer(page); @@ -123,7 +154,7 @@ for (const viewport of [ { width: 1440, height: 900 }, { width: 2560, height: 1440 }, ]) { - test(`keeps information content within the viewport at ${viewport.width}px`, async ({ page }, testInfo) => { + test(`keeps information content within the viewport at ${viewport.width}px`, async ({ page }) => { await page.setViewportSize(viewport); await loadInformationLayer(page); @@ -138,13 +169,12 @@ for (const viewport of [ }; }); - await capture(page, testInfo, `information-layer-${viewport.width}.png`); expect(geometry.noOverflow).toBe(true); expect(geometry.allBoxesFit).toBe(true); }); } -test('uses explicit desktop layout families while preserving the primary-project road corridor', async ({ page }, testInfo) => { +test('uses explicit desktop layout families while preserving the primary-project road corridor', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await loadInformationLayer(page); @@ -161,7 +191,6 @@ test('uses explicit desktop layout families while preserving the primary-project expect(projects[0]!.right).toBeLessThanOrEqual(1440 * 0.51); expect(projects[1]!.left).toBeGreaterThanOrEqual(1440 * 0.53); expect(projects.every((project) => project.width <= 1440 * 0.47)).toBe(true); - await capture(page, testInfo, 'information-layer-desktop-corridor.png'); }); test('lays out synthetic portrait evidence in a desktop strip and preserves mobile reflow', async ({ page }) => { @@ -188,7 +217,38 @@ test('lays out synthetic portrait evidence in a desktop strip and preserves mobi expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); }); -test('uses a stable tablet rhythm and a single readable mobile column with usable navigation targets', async ({ page }, testInfo) => { +test('aligns chart-pair captions for deliberately mixed source aspect ratios', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await loadInformationLayer(page); + + const captions = page.locator('.project-media-gallery[data-media-layout="chart-pair"] figcaption'); + await expect(captions).toHaveCount(2); + const tops = await captions.evaluateAll((nodes) => nodes.map((node) => node.getBoundingClientRect().top)); + expect(Math.abs(tops[0]! - tops[1]!)).toBeLessThanOrEqual(1); +}); + +test('keeps portrait evidence and its caption within an isolated print-page height', async ({ page }) => { + await page.setViewportSize({ width: 800, height: 1000 }); + await page.emulateMedia({ media: 'print' }); + await loadInformationLayer(page); + + const portrait = page.locator('.project-media-gallery[data-media-layout="portrait-strip"] figure').first(); + const printGeometry = await portrait.evaluate((figure) => { + const image = figure.querySelector('img'); + const caption = figure.querySelector('figcaption'); + if (image === null || caption === null) throw new Error('Missing synthetic portrait image or caption.'); + const imageRect = image.getBoundingClientRect(); + const captionRect = caption.getBoundingClientRect(); + const figureStyle = getComputedStyle(figure); + return { + mediaAndCaptionHeight: imageRect.height + captionRect.height + Number.parseFloat(figureStyle.marginBlockEnd), + printableHeight: window.innerHeight - 3 * 16, + }; + }); + expect(printGeometry.mediaAndCaptionHeight).toBeLessThanOrEqual(printGeometry.printableHeight); +}); + +test('uses a stable tablet rhythm and a single readable mobile column with usable navigation targets', async ({ page }) => { await page.setViewportSize({ width: 768, height: 1024 }); await loadInformationLayer(page); const tabletPanels = await page.locator('.portfolio-main > .portfolio-section').evaluateAll((sections) => @@ -198,7 +258,6 @@ test('uses a stable tablet rhythm and a single readable mobile column with usabl expect(tabletPanels['left-timeline'].left).toBeLessThan(768 * 0.1); expect(tabletPanels['full-diagram'].width).toBeGreaterThan(768 * 0.8); expect(tabletPanels['compact-rail'].left).toBeGreaterThan(768 * 0.3); - await capture(page, testInfo, 'information-layer-tablet.png'); await page.setViewportSize({ width: 320, height: 800 }); await loadInformationLayer(page); @@ -215,10 +274,9 @@ test('uses a stable tablet rhythm and a single readable mobile column with usabl }); expect(mobile.singleColumn).toBe(true); expect(mobile.touchTargets).toBe(true); - await capture(page, testInfo, 'information-layer-mobile.png'); }); -test('turns the infrastructure route from a horizontal trust flow into a readable mobile stack', async ({ page }, testInfo) => { +test('turns the infrastructure route from a horizontal trust flow into a readable mobile stack', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await loadInformationLayer(page); @@ -231,7 +289,6 @@ test('turns the infrastructure route from a horizontal trust flow into a readabl expect(desktop.slice(0, 4).every((node) => Math.abs(node.top - desktop[0]!.top) <= 1)).toBe(true); expect(desktop[4]!.top).toBeGreaterThanOrEqual(desktop[3]!.bottom); expect(desktop.slice(0, 4).every((node, index, nodes) => index === 0 || node.left > nodes[index - 1]!.left)).toBe(true); - await capture(page, testInfo, 'infrastructure-flow-desktop.png'); await page.setViewportSize({ width: 320, height: 800 }); await loadInformationLayer(page); @@ -244,7 +301,6 @@ test('turns the infrastructure route from a horizontal trust flow into a readabl expect(mobile.every((node) => Math.abs(node.left - mobile[0]!.left) <= 1)).toBe(true); expect(mobile.every((node) => node.width >= 200)).toBe(true); expect(mobile.every((node, index, nodes) => index === 0 || node.top > nodes[index - 1]!.bottom)).toBe(true); - await capture(page, testInfo, 'infrastructure-flow-mobile.png'); }); test('keeps the compact HUD in one safe lower row without covering the road corridor', async ({ page }) => { diff --git a/tests/unit/ProjectMedia.test.ts b/tests/unit/ProjectMedia.test.ts index 52f0d16..4ca7d80 100644 --- a/tests/unit/ProjectMedia.test.ts +++ b/tests/unit/ProjectMedia.test.ts @@ -234,6 +234,7 @@ describe('ProjectMediaGallery', () => { ], 'portrait-strip'); expect(wrapper.attributes('data-media-layout')).toBe('portrait-strip'); + expect(wrapper.findAll('.project-media-gallery__item')).toHaveLength(3); expect(wrapper.findAll('figure')).toHaveLength(3); expect(wrapper.findAll('img').map((image) => image.attributes('loading'))).toEqual(['lazy', 'lazy', 'lazy']); expect(wrapper.text()).toContain('Fictional demo profile');