feat: add responsive professional information layer
This commit is contained in:
154
tests/browser/responsive-layout.spec.ts
Normal file
154
tests/browser/responsive-layout.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { expect, test, type Page, type TestInfo } from '@playwright/test';
|
||||
|
||||
const stylesheetFiles = [
|
||||
'src/styles/tokens.css',
|
||||
'src/styles/base.css',
|
||||
'src/styles/layout.css',
|
||||
'src/styles/components.css',
|
||||
'src/styles/print.css',
|
||||
];
|
||||
|
||||
function informationLayerCss(): string {
|
||||
return stylesheetFiles
|
||||
.map((file) => {
|
||||
const path = resolve(process.cwd(), file);
|
||||
return existsSync(path) ? readFileSync(path, 'utf8') : '';
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function informationLayerHarness(): string {
|
||||
const navigation = ['Start', 'Profile', 'Experience', 'Infrastructure', 'Selected builds', 'Contact'];
|
||||
const sections = ['start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'contact'];
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>${informationLayerCss()}</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#portfolio-content">Skip to portfolio content</a>
|
||||
<div class="portfolio-shell">
|
||||
<header class="site-header">
|
||||
<p class="route-label">00 / INTRODUCTION</p>
|
||||
<h1>Luca Pîslaru</h1>
|
||||
<p class="role">System Administrator & Full-Stack Developer</p>
|
||||
<nav class="site-navigation" aria-label="Portfolio sections">
|
||||
<ul>${navigation.map((label, index) => `<li><a href="#${sections[index] ?? 'contact'}">${label}</a></li>`).join('')}</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main id="portfolio-content" class="portfolio-main">
|
||||
${sections.map((id, index) => `
|
||||
<section id="${id}" class="portfolio-section" data-section-index="${index + 1}" aria-labelledby="${id}-heading">
|
||||
<p class="route-label">${String(index + 1).padStart(2, '0')} / ${id.toUpperCase()}</p>
|
||||
<h2 id="${id}-heading">${navigation[index] ?? 'Contact'}</h2>
|
||||
<p>Reliable systems and software evidence for hiring managers and technical interviewers.</p>
|
||||
<article>
|
||||
<h3>Operational detail</h3>
|
||||
<p>Long unbroken technical reference: serviceorchestrationandobservabilityintegrationwithaverylongidentifierexampletestarchitectureproductionreadiness.</p>
|
||||
<a href="https://example.test/portfolio/reference">Open project reference</a>
|
||||
</article>
|
||||
</section>
|
||||
`).join('')}
|
||||
</main>
|
||||
<footer class="site-footer"><a href="mailto:luca@example.test">luca@example.test</a></footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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), fullPage: true });
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 320, height: 800 },
|
||||
{ width: 768, height: 1024 },
|
||||
{ width: 1440, height: 900 },
|
||||
{ width: 2560, height: 1440 },
|
||||
]) {
|
||||
test(`keeps information content within the viewport at ${viewport.width}px`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize(viewport);
|
||||
await loadInformationLayer(page);
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
const boxes = [...document.querySelectorAll<HTMLElement>('.portfolio-shell :is(h1, h2, h3, p, a, article, section)')]
|
||||
.map((element) => element.getBoundingClientRect())
|
||||
.filter((rect) => rect.width > 0 && rect.height > 0);
|
||||
return {
|
||||
noOverflow: root.scrollWidth <= root.clientWidth + 1,
|
||||
allBoxesFit: boxes.every((rect) => rect.left >= -1 && rect.right <= root.clientWidth + 1),
|
||||
};
|
||||
});
|
||||
|
||||
await capture(page, testInfo, `information-layer-${viewport.width}.png`);
|
||||
expect(geometry.noOverflow).toBe(true);
|
||||
expect(geometry.allBoxesFit).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('uses alternating desktop panels while preserving a clear central road corridor', async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await loadInformationLayer(page);
|
||||
|
||||
const panels = await page.locator('.portfolio-main > .portfolio-section').evaluateAll((sections, viewportWidth) =>
|
||||
sections.map((section) => {
|
||||
const rect = section.getBoundingClientRect();
|
||||
return { left: rect.left, right: rect.right, widthRatio: rect.width / Number(viewportWidth) };
|
||||
}), 1440,
|
||||
);
|
||||
|
||||
expect(panels.every((panel) => panel.widthRatio >= 0.4 && panel.widthRatio <= 0.45)).toBe(true);
|
||||
expect(panels.every((panel, index) => index % 2 === 0 ? panel.right <= 1440 * 0.461 : panel.left >= 1440 * 0.539)).toBe(true);
|
||||
await capture(page, testInfo, 'information-layer-desktop-corridor.png');
|
||||
});
|
||||
|
||||
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);
|
||||
const tabletPanels = await page.locator('.portfolio-main > .portfolio-section').evaluateAll((sections) =>
|
||||
sections.map((section) => section.getBoundingClientRect()),
|
||||
);
|
||||
expect(tabletPanels.every((panel) => panel.width < 768 * 0.7)).toBe(true);
|
||||
expect(tabletPanels.every((panel, index) => index % 2 === 0 ? panel.left < 768 / 2 : panel.left >= 768 / 2)).toBe(true);
|
||||
await capture(page, testInfo, 'information-layer-tablet.png');
|
||||
|
||||
await page.setViewportSize({ width: 320, height: 800 });
|
||||
await loadInformationLayer(page);
|
||||
const mobile = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
const panels = [...document.querySelectorAll<HTMLElement>('.portfolio-main > .portfolio-section')]
|
||||
.map((section) => section.getBoundingClientRect());
|
||||
const navigationTargets = [...document.querySelectorAll<HTMLElement>('.site-navigation a')]
|
||||
.map((link) => link.getBoundingClientRect());
|
||||
return {
|
||||
singleColumn: panels.every((panel) => panel.left >= 0 && panel.right <= root.clientWidth && panel.width >= root.clientWidth * 0.85),
|
||||
touchTargets: navigationTargets.every((target) => target.width >= 44 && target.height >= 44),
|
||||
};
|
||||
});
|
||||
expect(mobile.singleColumn).toBe(true);
|
||||
expect(mobile.touchTargets).toBe(true);
|
||||
await capture(page, testInfo, 'information-layer-mobile.png');
|
||||
});
|
||||
|
||||
test('removes nonessential information-layer motion when reduced motion is requested', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await loadInformationLayer(page);
|
||||
const duration = await page.locator('.portfolio-section').first().evaluate((element) => getComputedStyle(element).transitionDuration);
|
||||
expect(duration).toBe('0.001s');
|
||||
});
|
||||
|
||||
test('creates a clean print portfolio with navigation removed', async ({ page }) => {
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
await loadInformationLayer(page);
|
||||
await expect(page.locator('.site-navigation')).toBeHidden();
|
||||
await expect(page.locator('.portfolio-section').first()).toHaveCSS('break-inside', 'avoid');
|
||||
});
|
||||
Reference in New Issue
Block a user