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>
<div v-if="media.length > 0" class="project-media-gallery" :data-media-layout="layout">
<ProjectMedia
<div
v-for="presentation in media"
:key="`${presentation.kind}-${presentation.assetId}`"
class="project-media-gallery__item"
>
<ProjectMedia
:presentation="presentation"
:allow-automatic-video="allowAutomaticVideo"
/>
</div>
</div>
</template>

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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<void> {
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 }) => {
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 }) => {

View File

@@ -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');