feat: add evidence-led project media layouts

This commit is contained in:
2026-09-04 21:18:50 +03:00
parent 242d79599a
commit 9cec17e139
9 changed files with 352 additions and 15 deletions

View File

@@ -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 { clickNavigationLink, expect, test } from "../helpers/serve-dist";
@@ -10,6 +13,83 @@ const textSpacingOverride = `
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> {
const result = await page.evaluate(() => {
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 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);
});

View File

@@ -27,14 +27,50 @@ type InputValidator = { validateProductionInputs: (content: unknown, manifest: u
let vite: ViteDevServer;
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 () => {
vite = await createServer({ server: { middlewareMode: true, hmr: false, ws: false }, appType: 'custom' });
const { render } = await vite.ssrLoadModule('/src/entry-server.ts') as ServerRenderer;
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 inputs = validateProductionInputs(
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/'),
);
@@ -63,7 +99,7 @@ async function loadInformationLayer(page: Page): 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 }) => {
@@ -128,6 +164,30 @@ test('uses explicit desktop layout families while preserving the primary-project
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) => {
await page.setViewportSize({ width: 768, height: 1024 });
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('.portfolio-section').first()).toHaveCSS('break-inside', 'avoid');
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);
});