fix: contain project media layouts

This commit is contained in:
2026-09-04 21:48:51 +03:00
parent 9cec17e139
commit 8f358c2e0d
5 changed files with 117 additions and 25 deletions

View File

@@ -11,11 +11,15 @@ withDefaults(defineProps<{
<template> <template>
<div v-if="media.length > 0" class="project-media-gallery" :data-media-layout="layout"> <div v-if="media.length > 0" class="project-media-gallery" :data-media-layout="layout">
<ProjectMedia <div
v-for="presentation in media" v-for="presentation in media"
:key="`${presentation.kind}-${presentation.assetId}`" :key="`${presentation.kind}-${presentation.assetId}`"
class="project-media-gallery__item"
>
<ProjectMedia
:presentation="presentation" :presentation="presentation"
:allow-automatic-video="allowAutomaticVideo" :allow-automatic-video="allowAutomaticVideo"
/> />
</div> </div>
</div>
</template> </template>

View File

@@ -682,13 +682,14 @@
overflow: visible; overflow: visible;
} }
.project-media-gallery__item,
.project-media-gallery .project-media { .project-media-gallery .project-media {
position: relative; position: relative;
min-inline-size: 0; min-inline-size: 0;
overflow: visible; overflow: visible;
} }
.project-media-gallery .project-media:focus-within { .project-media-gallery__item:focus-within {
z-index: 1; z-index: 1;
} }
@@ -704,15 +705,15 @@
padding-inline: 0.5rem; 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; 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; 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; margin-block-start: 0.5rem;
} }
@@ -720,6 +721,32 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start; 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, .project-media img,
@@ -894,8 +921,8 @@
padding-inline: 0; padding-inline: 0;
} }
.project-media-gallery[data-media-layout="portrait-strip"] > .project-media, .project-media-gallery[data-media-layout="portrait-strip"] > .project-media-gallery__item,
.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-block-start: 0; margin-block-start: 0;
margin-inline-start: 0; margin-inline-start: 0;
} }

View File

@@ -117,7 +117,11 @@
.project-media-gallery img, .project-media-gallery img,
.project-media-gallery video { .project-media-gallery video {
max-inline-size: 100%; max-inline-size: 100%;
max-block-size: min(9in, calc(100vh - 6rem));
inline-size: auto;
block-size: auto; block-size: auto;
margin-inline: auto;
object-fit: contain;
} }
a { a {

View File

@@ -1,6 +1,6 @@
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path'; 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'; import { createServer, type ViteDevServer } from 'vite';
const stylesheetFiles = [ const stylesheetFiles = [
@@ -33,6 +33,11 @@ const syntheticProjectMedia = [
{ kind: 'image', assetId: 'synthetic-portrait-three', alt: 'Synthetic schedule evidence.', caption: 'Fictional demo schedule.' }, { kind: 'image', assetId: 'synthetic-portrait-three', alt: 'Synthetic schedule evidence.', caption: 'Fictional demo schedule.' },
] as const; ] 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 } { function withSyntheticProjectMedia(contentInput: unknown, manifestInput: unknown): { content: unknown; manifest: unknown } {
const content = structuredClone(contentInput) as { const content = structuredClone(contentInput) as {
projects: { selected: Array<{ assetIds: string[]; media?: unknown; mediaLayout?: unknown }> }; 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.assetIds = syntheticProjectMedia.map((media) => media.assetId);
project.media = syntheticProjectMedia; project.media = syntheticProjectMedia;
project.mediaLayout = 'portrait-strip'; 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) => ({ manifest.assets.push(...syntheticProjectMedia.map((media, index) => ({
id: media.assetId, id: media.assetId,
fileName: `${media.assetId}.webp`, fileName: `${media.assetId}.webp`,
@@ -55,6 +64,32 @@ function withSyntheticProjectMedia(contentInput: unknown, manifestInput: unknown
license: 'Test-only synthetic asset', license: 'Test-only synthetic asset',
attribution: 'Synthetic test fixture', 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 }; return { content, manifest };
} }
@@ -98,10 +133,6 @@ async function loadInformationLayer(page: Page): Promise<void> {
await page.setContent(informationLayerHarness()); await page.setContent(informationLayerHarness());
} }
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
await page.screenshot({ path: testInfo.outputPath(name) });
}
test('renders the validated eight-section portfolio structure before responsive checks', async ({ page }) => { test('renders the validated eight-section portfolio structure before responsive checks', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await loadInformationLayer(page); await loadInformationLayer(page);
@@ -123,7 +154,7 @@ for (const viewport of [
{ width: 1440, height: 900 }, { width: 1440, height: 900 },
{ width: 2560, height: 1440 }, { 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 page.setViewportSize(viewport);
await loadInformationLayer(page); 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.noOverflow).toBe(true);
expect(geometry.allBoxesFit).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 page.setViewportSize({ width: 1440, height: 900 });
await loadInformationLayer(page); 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[0]!.right).toBeLessThanOrEqual(1440 * 0.51);
expect(projects[1]!.left).toBeGreaterThanOrEqual(1440 * 0.53); expect(projects[1]!.left).toBeGreaterThanOrEqual(1440 * 0.53);
expect(projects.every((project) => project.width <= 1440 * 0.47)).toBe(true); 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 }) => { 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); 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 page.setViewportSize({ width: 768, height: 1024 });
await loadInformationLayer(page); await loadInformationLayer(page);
const tabletPanels = await page.locator('.portfolio-main > .portfolio-section').evaluateAll((sections) => 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['left-timeline'].left).toBeLessThan(768 * 0.1);
expect(tabletPanels['full-diagram'].width).toBeGreaterThan(768 * 0.8); expect(tabletPanels['full-diagram'].width).toBeGreaterThan(768 * 0.8);
expect(tabletPanels['compact-rail'].left).toBeGreaterThan(768 * 0.3); 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 page.setViewportSize({ width: 320, height: 800 });
await loadInformationLayer(page); 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.singleColumn).toBe(true);
expect(mobile.touchTargets).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 page.setViewportSize({ width: 1440, height: 900 });
await loadInformationLayer(page); 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.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[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); 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 page.setViewportSize({ width: 320, height: 800 });
await loadInformationLayer(page); 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) => Math.abs(node.left - mobile[0]!.left) <= 1)).toBe(true);
expect(mobile.every((node) => node.width >= 200)).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); 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 }) => { test('keeps the compact HUD in one safe lower row without covering the road corridor', async ({ page }) => {

View File

@@ -234,6 +234,7 @@ describe('ProjectMediaGallery', () => {
], 'portrait-strip'); ], 'portrait-strip');
expect(wrapper.attributes('data-media-layout')).toBe('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('figure')).toHaveLength(3);
expect(wrapper.findAll('img').map((image) => image.attributes('loading'))).toEqual(['lazy', 'lazy', 'lazy']); expect(wrapper.findAll('img').map((image) => image.attributes('loading'))).toEqual(['lazy', 'lazy', 'lazy']);
expect(wrapper.text()).toContain('Fictional demo profile'); expect(wrapper.text()).toContain('Fictional demo profile');