feat: add evidence-led project media layouts
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PublicProject } from '@/content/types';
|
import type { PublicProject } from '@/content/types';
|
||||||
import ProjectMedia from './ProjectMedia.vue';
|
import ProjectMediaGallery from './ProjectMediaGallery.vue';
|
||||||
|
|
||||||
withDefaults(defineProps<{ project: Readonly<PublicProject>; allowAutomaticVideo?: boolean }>(), {
|
withDefaults(defineProps<{ project: Readonly<PublicProject>; allowAutomaticVideo?: boolean }>(), {
|
||||||
allowAutomaticVideo: false,
|
allowAutomaticVideo: false,
|
||||||
@@ -8,13 +8,17 @@ withDefaults(defineProps<{ project: Readonly<PublicProject>; allowAutomaticVideo
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article class="project-card" :data-project-id="project.id" :data-maturity="project.maturity">
|
<article
|
||||||
|
class="project-card"
|
||||||
|
:data-project-id="project.id"
|
||||||
|
:data-maturity="project.maturity"
|
||||||
|
:data-media-layout="project.mediaLayout ?? 'none'"
|
||||||
|
>
|
||||||
<h3>{{ project.title }}</h3>
|
<h3>{{ project.title }}</h3>
|
||||||
<p>{{ project.summary }}</p>
|
<p>{{ project.summary }}</p>
|
||||||
<ProjectMedia
|
<ProjectMediaGallery
|
||||||
v-for="media in project.media ?? []"
|
:media="project.media ?? []"
|
||||||
:key="`${media.kind}-${media.assetId}`"
|
:layout="project.mediaLayout ?? 'single'"
|
||||||
:presentation="media"
|
|
||||||
:allow-automatic-video="allowAutomaticVideo"
|
:allow-automatic-video="allowAutomaticVideo"
|
||||||
/>
|
/>
|
||||||
<p>{{ project.contribution }}</p>
|
<p>{{ project.contribution }}</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PublicProject } from '@/content/types';
|
import type { PublicProject } from '@/content/types';
|
||||||
import ProjectMedia from './ProjectMedia.vue';
|
import ProjectMediaGallery from './ProjectMediaGallery.vue';
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
withDefaults(defineProps<{
|
||||||
project: Readonly<PublicProject>;
|
project: Readonly<PublicProject>;
|
||||||
@@ -13,15 +13,19 @@ withDefaults(defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article class="project-case-study" :data-project-id="project.id" :data-project-priority="priority">
|
<article
|
||||||
|
class="project-case-study"
|
||||||
|
:data-project-id="project.id"
|
||||||
|
:data-project-priority="priority"
|
||||||
|
:data-media-layout="project.mediaLayout ?? 'none'"
|
||||||
|
>
|
||||||
<header class="project-case-study__header">
|
<header class="project-case-study__header">
|
||||||
<h3>{{ project.title }}</h3>
|
<h3>{{ project.title }}</h3>
|
||||||
<p>{{ project.summary }}</p>
|
<p>{{ project.summary }}</p>
|
||||||
</header>
|
</header>
|
||||||
<ProjectMedia
|
<ProjectMediaGallery
|
||||||
v-for="media in project.media ?? []"
|
:media="project.media ?? []"
|
||||||
:key="`${media.kind}-${media.assetId}`"
|
:layout="project.mediaLayout ?? 'single'"
|
||||||
:presentation="media"
|
|
||||||
:allow-automatic-video="allowAutomaticVideo"
|
:allow-automatic-video="allowAutomaticVideo"
|
||||||
/>
|
/>
|
||||||
<dl class="project-evidence">
|
<dl class="project-evidence">
|
||||||
|
|||||||
21
src/components/ProjectMediaGallery.vue
Normal file
21
src/components/ProjectMediaGallery.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ProjectMedia from './ProjectMedia.vue';
|
||||||
|
import type { MediaPresentation, ProjectMediaLayout } from '@/content/types';
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
media: readonly MediaPresentation[];
|
||||||
|
layout?: ProjectMediaLayout;
|
||||||
|
allowAutomaticVideo?: boolean;
|
||||||
|
}>(), { layout: 'single', allowAutomaticVideo: false });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="media.length > 0" class="project-media-gallery" :data-media-layout="layout">
|
||||||
|
<ProjectMedia
|
||||||
|
v-for="presentation in media"
|
||||||
|
:key="`${presentation.kind}-${presentation.assetId}`"
|
||||||
|
:presentation="presentation"
|
||||||
|
:allow-automatic-video="allowAutomaticVideo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -485,6 +485,17 @@
|
|||||||
background: var(--surface-soft);
|
background: var(--surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 100rem) {
|
||||||
|
.project-case-study[data-project-id="private-medication-tracker"][data-media-layout]:not([data-media-layout="none"]) {
|
||||||
|
inline-size: min(72rem, 88%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-case-study[data-project-id="footy-tracker"][data-media-layout]:not([data-media-layout="none"]) {
|
||||||
|
inline-size: min(68rem, 82%);
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.project-case-study__header {
|
.project-case-study__header {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
@@ -661,6 +672,56 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-media-gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-inline-size: 0;
|
||||||
|
margin-block: var(--space-3);
|
||||||
|
isolation: isolate;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-media-gallery .project-media {
|
||||||
|
position: relative;
|
||||||
|
min-inline-size: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-media-gallery .project-media:focus-within {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-media-gallery[data-media-layout="single"] {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 48rem) {
|
||||||
|
.project-media-gallery[data-media-layout="portrait-strip"] {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0;
|
||||||
|
align-items: start;
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-media-gallery[data-media-layout="portrait-strip"] > .project-media + .project-media {
|
||||||
|
margin-inline-start: -0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-media-gallery[data-media-layout="portrait-strip"] > .project-media:nth-child(even) {
|
||||||
|
margin-block-start: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-media-gallery[data-media-layout="portrait-strip"] > .project-media:nth-child(3n) {
|
||||||
|
margin-block-start: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-media-gallery[data-media-layout="chart-pair"] {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.project-media img,
|
.project-media img,
|
||||||
.project-media video {
|
.project-media video {
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
@@ -822,9 +883,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project-card[data-maturity] {
|
.project-card[data-maturity] {
|
||||||
|
inline-size: 100%;
|
||||||
margin-inline: 0;
|
margin-inline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-media-gallery,
|
||||||
|
.project-media-gallery[data-media-layout="portrait-strip"],
|
||||||
|
.project-media-gallery[data-media-layout="chart-pair"] {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
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 {
|
||||||
|
margin-block-start: 0;
|
||||||
|
margin-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.contact-destination__copy {
|
.contact-destination__copy {
|
||||||
font-size: var(--step-1);
|
font-size: var(--step-1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,7 @@
|
|||||||
|
|
||||||
.portfolio-section[data-layout="project-stage"] {
|
.portfolio-section[data-layout="project-stage"] {
|
||||||
inline-size: 92%;
|
inline-size: 92%;
|
||||||
|
min-inline-size: 0;
|
||||||
margin-inline: 4%;
|
margin-inline: 4%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -143,6 +144,10 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portfolio-section[data-layout="project-stage"] .project-stage__projects {
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.portfolio-section[data-layout="project-stage"] > h2,
|
.portfolio-section[data-layout="project-stage"] > h2,
|
||||||
.portfolio-section[data-layout="project-stage"] > .section-eyebrow {
|
.portfolio-section[data-layout="project-stage"] > .section-eyebrow {
|
||||||
inline-size: fit-content;
|
inline-size: fit-content;
|
||||||
|
|||||||
@@ -101,6 +101,25 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-media-gallery,
|
||||||
|
.project-media-gallery[data-media-layout] {
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-media-gallery .project-media {
|
||||||
|
break-inside: avoid;
|
||||||
|
margin: 0 0 0.9rem;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-media-gallery img,
|
||||||
|
.project-media-gallery video {
|
||||||
|
max-inline-size: 100%;
|
||||||
|
block-size: auto;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { createServer, type ViteDevServer } from "vite";
|
||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
import { clickNavigationLink, expect, test } from "../helpers/serve-dist";
|
import { clickNavigationLink, expect, test } from "../helpers/serve-dist";
|
||||||
|
|
||||||
@@ -10,6 +13,83 @@ const textSpacingOverride = `
|
|||||||
p { margin-block-end: 2em !important; }
|
p { margin-block-end: 2em !important; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const stylesheetFiles = [
|
||||||
|
"src/styles/tokens.css",
|
||||||
|
"src/styles/base.css",
|
||||||
|
"src/styles/layout.css",
|
||||||
|
"src/styles/components.css",
|
||||||
|
"src/styles/world.css",
|
||||||
|
"src/styles/print.css",
|
||||||
|
];
|
||||||
|
|
||||||
|
type ServerRenderer = { render: (url: string, inputs: unknown) => Promise<string> };
|
||||||
|
type InputValidator = { validateProductionInputs: (content: unknown, manifest: unknown, site: unknown, preparedAssetRoot: URL) => unknown };
|
||||||
|
|
||||||
|
let mediaVite: ViteDevServer;
|
||||||
|
let syntheticMediaDocument = "";
|
||||||
|
|
||||||
|
function mediaStyles(): string {
|
||||||
|
return stylesheetFiles
|
||||||
|
.map((file) => {
|
||||||
|
const path = resolve(process.cwd(), file);
|
||||||
|
return existsSync(path) ? readFileSync(path, "utf8") : "";
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSyntheticProjectMedia(contentInput: unknown, manifestInput: unknown): { content: unknown; manifest: unknown } {
|
||||||
|
const content = structuredClone(contentInput) as {
|
||||||
|
projects: { selected: Array<{ assetIds: string[]; media?: unknown; mediaLayout?: unknown }> };
|
||||||
|
};
|
||||||
|
const manifest = structuredClone(manifestInput) as { assets: Array<Record<string, unknown>> };
|
||||||
|
const media = [
|
||||||
|
{ kind: "image", assetId: "synthetic-reflow-one", alt: "Synthetic profile evidence.", caption: "Fictional demo profile." },
|
||||||
|
{ kind: "image", assetId: "synthetic-reflow-two", alt: "Synthetic settings evidence.", caption: "Fictional demo settings." },
|
||||||
|
{ kind: "image", assetId: "synthetic-reflow-three", alt: "Synthetic schedule evidence.", caption: "Fictional demo schedule." },
|
||||||
|
] as const;
|
||||||
|
const project = content.projects.selected[0]!;
|
||||||
|
|
||||||
|
project.assetIds = media.map((presentation) => presentation.assetId);
|
||||||
|
project.media = media;
|
||||||
|
project.mediaLayout = "portrait-strip";
|
||||||
|
manifest.assets.push(...media.map((presentation, index) => ({
|
||||||
|
id: presentation.assetId,
|
||||||
|
fileName: `${presentation.assetId}.webp`,
|
||||||
|
sha256: `${index + 1}`.repeat(64),
|
||||||
|
mediaType: "image/webp",
|
||||||
|
width: 800,
|
||||||
|
height: 1200,
|
||||||
|
durationMs: null,
|
||||||
|
rightsHolder: "Synthetic test fixture",
|
||||||
|
license: "Test-only synthetic asset",
|
||||||
|
attribution: "Synthetic test fixture",
|
||||||
|
})));
|
||||||
|
|
||||||
|
return { content, manifest };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
mediaVite = await createServer({ server: { middlewareMode: true, hmr: false, ws: false }, appType: "custom" });
|
||||||
|
const { render } = await mediaVite.ssrLoadModule("/src/entry-server.ts") as ServerRenderer;
|
||||||
|
const { validateProductionInputs } = await mediaVite.ssrLoadModule("/src/content/schemas.ts") as InputValidator;
|
||||||
|
const fixture = (file: string): unknown => JSON.parse(readFileSync(resolve(process.cwd(), file), "utf8")) as unknown;
|
||||||
|
const syntheticFixture = withSyntheticProjectMedia(
|
||||||
|
fixture("content/public/portfolio-content.json"),
|
||||||
|
fixture("content/public/asset-manifest.json"),
|
||||||
|
);
|
||||||
|
const inputs = validateProductionInputs(
|
||||||
|
syntheticFixture.content,
|
||||||
|
syntheticFixture.manifest,
|
||||||
|
fixture("config/site.preview.json"),
|
||||||
|
new URL("file:///prepared-assets/"),
|
||||||
|
);
|
||||||
|
syntheticMediaDocument = `<!doctype html><html lang="en"><head><meta name="viewport" content="width=device-width, initial-scale=1"><style>${mediaStyles()}</style></head><body>${await render("/", inputs)}</body></html>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await mediaVite.close();
|
||||||
|
});
|
||||||
|
|
||||||
async function assertReflowGeometry(page: Page): Promise<void> {
|
async function assertReflowGeometry(page: Page): Promise<void> {
|
||||||
const result = await page.evaluate(() => {
|
const result = await page.evaluate(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@@ -102,3 +182,15 @@ test("preserves content and controls with the WCAG text-spacing override", async
|
|||||||
await page.addStyleTag({ content: textSpacingOverride });
|
await page.addStyleTag({ content: textSpacingOverride });
|
||||||
await assertReflowGeometry(page);
|
await assertReflowGeometry(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps synthetic portrait media in DOM order at 320 CSS pixels", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 320, height: 800 });
|
||||||
|
await page.setContent(syntheticMediaDocument);
|
||||||
|
|
||||||
|
const gallery = page.locator('.project-media-gallery[data-media-layout="portrait-strip"]');
|
||||||
|
await expect(gallery).toHaveCount(1);
|
||||||
|
expect(await gallery.evaluate((node) => node.scrollWidth <= node.clientWidth || getComputedStyle(node).overflowX === "auto")).toBe(true);
|
||||||
|
expect(await gallery.locator("figure").evaluateAll((figures) => figures.map((figure) => figure.textContent)))
|
||||||
|
.toEqual(["Fictional demo profile.", "Fictional demo settings.", "Fictional demo schedule."]);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,14 +27,50 @@ type InputValidator = { validateProductionInputs: (content: unknown, manifest: u
|
|||||||
let vite: ViteDevServer;
|
let vite: ViteDevServer;
|
||||||
let renderedPortfolio = '';
|
let renderedPortfolio = '';
|
||||||
|
|
||||||
|
const syntheticProjectMedia = [
|
||||||
|
{ kind: 'image', assetId: 'synthetic-portrait-one', alt: 'Synthetic profile evidence.', caption: 'Fictional demo profile.' },
|
||||||
|
{ kind: 'image', assetId: 'synthetic-portrait-two', alt: 'Synthetic settings evidence.', caption: 'Fictional demo settings.' },
|
||||||
|
{ kind: 'image', assetId: 'synthetic-portrait-three', alt: 'Synthetic schedule evidence.', caption: 'Fictional demo schedule.' },
|
||||||
|
] 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 }> };
|
||||||
|
};
|
||||||
|
const manifest = structuredClone(manifestInput) as { assets: Array<Record<string, unknown>> };
|
||||||
|
const project = content.projects.selected[0]!;
|
||||||
|
|
||||||
|
project.assetIds = syntheticProjectMedia.map((media) => media.assetId);
|
||||||
|
project.media = syntheticProjectMedia;
|
||||||
|
project.mediaLayout = 'portrait-strip';
|
||||||
|
manifest.assets.push(...syntheticProjectMedia.map((media, index) => ({
|
||||||
|
id: media.assetId,
|
||||||
|
fileName: `${media.assetId}.webp`,
|
||||||
|
sha256: `${index + 1}`.repeat(64),
|
||||||
|
mediaType: 'image/webp',
|
||||||
|
width: 800,
|
||||||
|
height: 1200,
|
||||||
|
durationMs: null,
|
||||||
|
rightsHolder: 'Synthetic test fixture',
|
||||||
|
license: 'Test-only synthetic asset',
|
||||||
|
attribution: 'Synthetic test fixture',
|
||||||
|
})));
|
||||||
|
|
||||||
|
return { content, manifest };
|
||||||
|
}
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
vite = await createServer({ server: { middlewareMode: true, hmr: false, ws: false }, appType: 'custom' });
|
vite = await createServer({ server: { middlewareMode: true, hmr: false, ws: false }, appType: 'custom' });
|
||||||
const { render } = await vite.ssrLoadModule('/src/entry-server.ts') as ServerRenderer;
|
const { render } = await vite.ssrLoadModule('/src/entry-server.ts') as ServerRenderer;
|
||||||
const { validateProductionInputs } = await vite.ssrLoadModule('/src/content/schemas.ts') as InputValidator;
|
const { validateProductionInputs } = await vite.ssrLoadModule('/src/content/schemas.ts') as InputValidator;
|
||||||
const fixture = (file: string): unknown => JSON.parse(readFileSync(resolve(process.cwd(), file), 'utf8')) as unknown;
|
const fixture = (file: string): unknown => JSON.parse(readFileSync(resolve(process.cwd(), file), 'utf8')) as unknown;
|
||||||
const inputs = validateProductionInputs(
|
const syntheticFixture = withSyntheticProjectMedia(
|
||||||
fixture('content/public/portfolio-content.json'),
|
fixture('content/public/portfolio-content.json'),
|
||||||
fixture('content/public/asset-manifest.json'),
|
fixture('content/public/asset-manifest.json'),
|
||||||
|
);
|
||||||
|
const inputs = validateProductionInputs(
|
||||||
|
syntheticFixture.content,
|
||||||
|
syntheticFixture.manifest,
|
||||||
fixture('config/site.preview.json'),
|
fixture('config/site.preview.json'),
|
||||||
new URL('file:///prepared-assets/'),
|
new URL('file:///prepared-assets/'),
|
||||||
);
|
);
|
||||||
@@ -63,7 +99,7 @@ async function loadInformationLayer(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||||
await page.screenshot({ path: testInfo.outputPath(name), fullPage: true });
|
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 }) => {
|
||||||
@@ -128,6 +164,30 @@ test('uses explicit desktop layout families while preserving the primary-project
|
|||||||
await capture(page, testInfo, 'information-layer-desktop-corridor.png');
|
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 }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await loadInformationLayer(page);
|
||||||
|
|
||||||
|
const gallery = page.locator('.project-media-gallery[data-media-layout="portrait-strip"]');
|
||||||
|
await expect(gallery).toHaveCount(1);
|
||||||
|
const desktopFigures = await gallery.locator('figure').evaluateAll((figures) => figures.map((figure) => {
|
||||||
|
const rect = figure.getBoundingClientRect();
|
||||||
|
return { left: rect.left, width: rect.width };
|
||||||
|
}));
|
||||||
|
expect(desktopFigures).toHaveLength(3);
|
||||||
|
expect(desktopFigures[1]!.left).toBeGreaterThan(desktopFigures[0]!.left);
|
||||||
|
expect(desktopFigures[2]!.left).toBeGreaterThan(desktopFigures[1]!.left);
|
||||||
|
expect(desktopFigures.every((figure) => figure.width < 480)).toBe(true);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 320, height: 800 });
|
||||||
|
await loadInformationLayer(page);
|
||||||
|
await expect(gallery).toHaveCount(1);
|
||||||
|
expect(await gallery.evaluate((node) => node.scrollWidth <= node.clientWidth || getComputedStyle(node).overflowX === 'auto')).toBe(true);
|
||||||
|
expect(await gallery.locator('figure').evaluateAll((figures) => figures.map((figure) => figure.textContent)))
|
||||||
|
.toEqual(['Fictional demo profile.', 'Fictional demo settings.', 'Fictional demo schedule.']);
|
||||||
|
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('uses a stable tablet rhythm and a single readable mobile column with usable navigation targets', async ({ page }, testInfo) => {
|
||||||
await page.setViewportSize({ width: 768, height: 1024 });
|
await page.setViewportSize({ width: 768, height: 1024 });
|
||||||
await loadInformationLayer(page);
|
await loadInformationLayer(page);
|
||||||
@@ -260,6 +320,6 @@ test('creates a clean print portfolio with a visible label but no resume packagi
|
|||||||
await expect(page.locator('.print-portfolio-label')).toBeVisible();
|
await expect(page.locator('.print-portfolio-label')).toBeVisible();
|
||||||
await expect(page.locator('.portfolio-section').first()).toHaveCSS('break-inside', 'avoid');
|
await expect(page.locator('.portfolio-section').first()).toHaveCSS('break-inside', 'avoid');
|
||||||
const printCopy = await page.locator('body').innerText();
|
const printCopy = await page.locator('body').innerText();
|
||||||
expect(printCopy).not.toMatch(/résumé|resume/iu);
|
expect(printCopy).not.toMatch(/\b(?:Résumé|Resume)\b/u);
|
||||||
await expect(page.locator('a[download]')).toHaveCount(0);
|
await expect(page.locator('a[download]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { productionInputsKey } from '@/app';
|
import { productionInputsKey } from '@/app';
|
||||||
import AccessibleVideo from '@/components/AccessibleVideo.vue';
|
import AccessibleVideo from '@/components/AccessibleVideo.vue';
|
||||||
import ProjectMedia from '@/components/ProjectMedia.vue';
|
import ProjectMedia from '@/components/ProjectMedia.vue';
|
||||||
|
import ProjectMediaGallery from '@/components/ProjectMediaGallery.vue';
|
||||||
import { useLazyMedia } from '@/composables/useLazyMedia';
|
import { useLazyMedia } from '@/composables/useLazyMedia';
|
||||||
import { parsePortfolioContent, parsePublicAssetManifest } from '@/content/schemas';
|
import { parsePortfolioContent, parsePublicAssetManifest } from '@/content/schemas';
|
||||||
import type { MediaPresentation, ProductionInputs, PublicAssetManifest } from '@/content/types';
|
import type { MediaPresentation, ProductionInputs, PublicAssetManifest } from '@/content/types';
|
||||||
@@ -21,6 +22,11 @@ const manifest: PublicAssetManifest = {
|
|||||||
width: 1000, height: 700, durationMs: null, rightsHolder: 'Luca Pîslaru',
|
width: 1000, height: 700, durationMs: null, rightsHolder: 'Luca Pîslaru',
|
||||||
license: 'All rights reserved', attribution: 'Luca Pîslaru',
|
license: 'All rights reserved', attribution: 'Luca Pîslaru',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'project-portrait', fileName: 'project-portrait.webp', sha256: sha, mediaType: 'image/webp',
|
||||||
|
width: 800, height: 1200, durationMs: null, rightsHolder: 'Luca Pîslaru',
|
||||||
|
license: 'All rights reserved', attribution: 'Luca Pîslaru',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'project-loop', fileName: 'project-loop.webm', sha256: sha, mediaType: 'video/webm',
|
id: 'project-loop', fileName: 'project-loop.webm', sha256: sha, mediaType: 'video/webm',
|
||||||
width: 1280, height: 720, durationMs: 5_000, rightsHolder: 'Luca Pîslaru',
|
width: 1280, height: 720, durationMs: 5_000, rightsHolder: 'Luca Pîslaru',
|
||||||
@@ -83,6 +89,11 @@ const mountMedia = (presentation: MediaPresentation, allowAutomaticVideo = false
|
|||||||
global: { provide: { [productionInputsKey as symbol]: injectedInputs } },
|
global: { provide: { [productionInputsKey as symbol]: injectedInputs } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mountGallery = (media: readonly MediaPresentation[], layout: 'single' | 'portrait-strip' | 'chart-pair') => mount(ProjectMediaGallery, {
|
||||||
|
props: { media, layout },
|
||||||
|
global: { provide: { [productionInputsKey as symbol]: injectedInputs } },
|
||||||
|
});
|
||||||
|
|
||||||
describe('ProjectMedia', () => {
|
describe('ProjectMedia', () => {
|
||||||
it('resolves only manifest assets and keeps contextual wording in content', () => {
|
it('resolves only manifest assets and keeps contextual wording in content', () => {
|
||||||
const wrapper = mountMedia({
|
const wrapper = mountMedia({
|
||||||
@@ -214,6 +225,52 @@ describe('ProjectMedia', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ProjectMediaGallery', () => {
|
||||||
|
it('keeps three portrait images lazy, contextual, and in their supplied order', () => {
|
||||||
|
const wrapper = mountGallery([
|
||||||
|
{ kind: 'image', assetId: 'project-image', alt: 'Synthetic profile screen.', caption: 'Fictional demo profile.' },
|
||||||
|
{ kind: 'image', assetId: 'project-chart', alt: 'Synthetic chart screen.', caption: 'Fictional demo chart.' },
|
||||||
|
{ kind: 'image', assetId: 'project-portrait', alt: 'Synthetic portrait screen.', caption: 'Fictional demo profile settings.' },
|
||||||
|
], 'portrait-strip');
|
||||||
|
|
||||||
|
expect(wrapper.attributes('data-media-layout')).toBe('portrait-strip');
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves image and chart media semantics across single and chart-pair layouts', () => {
|
||||||
|
const single = mountGallery([
|
||||||
|
{ kind: 'image', assetId: 'project-image', alt: 'Synthetic application screen.', caption: 'Fictional demo profile.' },
|
||||||
|
], 'single');
|
||||||
|
const chartPair = mountGallery([
|
||||||
|
{
|
||||||
|
kind: 'chart', assetId: 'project-chart', alt: 'Synthetic trend chart.', caption: 'Fictional demo chart.',
|
||||||
|
chart: {
|
||||||
|
variables: 'Horizontal: month. Vertical: completion rate.',
|
||||||
|
trend: 'The modeled rate rises.',
|
||||||
|
conclusion: 'The modeled run finishes above its starting point.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ kind: 'image', assetId: 'project-image', alt: 'Synthetic supporting screen.', caption: 'Fictional supporting screen.' },
|
||||||
|
], 'chart-pair');
|
||||||
|
|
||||||
|
expect(single.attributes('data-media-layout')).toBe('single');
|
||||||
|
expect(single.get('img').attributes('loading')).toBe('lazy');
|
||||||
|
expect(single.get('figcaption').text()).toBe('Fictional demo profile.');
|
||||||
|
expect(chartPair.attributes('data-media-layout')).toBe('chart-pair');
|
||||||
|
expect(chartPair.findAll('figure')).toHaveLength(2);
|
||||||
|
expect(chartPair.get('[data-testid="chart-variables"]').text()).toContain('completion rate');
|
||||||
|
expect(chartPair.get('img').attributes('loading')).toBe('lazy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no gallery container for an empty media list', () => {
|
||||||
|
const wrapper = mountGallery([], 'single');
|
||||||
|
|
||||||
|
expect(wrapper.find('.project-media-gallery').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('useLazyMedia', () => {
|
describe('useLazyMedia', () => {
|
||||||
it('fails closed without an observer and supports an explicit request', async () => {
|
it('fails closed without an observer and supports an explicit request', async () => {
|
||||||
vi.stubGlobal('IntersectionObserver', undefined);
|
vi.stubGlobal('IntersectionObserver', undefined);
|
||||||
|
|||||||
Reference in New Issue
Block a user