fix: validate responsive real-content targets
This commit is contained in:
@@ -34,6 +34,7 @@ const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id
|
|||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<h1>{{ content.identity.name }}</h1>
|
<h1>{{ content.identity.name }}</h1>
|
||||||
<p>{{ content.identity.role }}</p>
|
<p>{{ content.identity.role }}</p>
|
||||||
|
<p class="print-portfolio-label" aria-hidden="true">Portfolio</p>
|
||||||
<SiteNavigation :items="canonicalNavigation" />
|
<SiteNavigation :items="canonicalNavigation" />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ defineProps<{
|
|||||||
<template>
|
<template>
|
||||||
<p>{{ identity.name }}</p>
|
<p>{{ identity.name }}</p>
|
||||||
<p>{{ identity.role }}</p>
|
<p>{{ identity.role }}</p>
|
||||||
<a :href="`mailto:${contact.email}`">{{ contact.email }}</a>
|
<a class="contact-link" :href="`mailto:${contact.email}`">{{ contact.email }}</a>
|
||||||
<p>{{ contact.cvMessage }}</p>
|
<p>{{ contact.cvMessage }}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="link in contact.social" :key="link.service">
|
<li v-for="link in contact.social" :key="link.service">
|
||||||
<a :href="link.url" target="_blank" rel="noopener noreferrer">{{ link.service }}</a>
|
<a class="contact-social-link" :href="link.url" target="_blank" rel="noopener noreferrer">{{ link.service }}</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ defineProps<{ project: Readonly<PublicProject> }>();
|
|||||||
<p>{{ project.summary }}</p>
|
<p>{{ project.summary }}</p>
|
||||||
<p>{{ project.contribution }}</p>
|
<p>{{ project.contribution }}</p>
|
||||||
<p>{{ project.outcome }}</p>
|
<p>{{ project.outcome }}</p>
|
||||||
<a v-if="project.visibility === 'public' && project.url" :href="project.url" target="_blank" rel="noopener noreferrer">{{ project.title }}</a>
|
<a v-if="project.visibility === 'public' && project.url" class="project-link" :href="project.url" target="_blank" rel="noopener noreferrer">{{ project.title }}</a>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -22,6 +22,6 @@ defineProps<{ project: Readonly<PublicProject> }>();
|
|||||||
<dt>Outcome</dt>
|
<dt>Outcome</dt>
|
||||||
<dd>{{ project.outcome }}</dd>
|
<dd>{{ project.outcome }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<a v-if="project.visibility === 'public' && project.url" :href="project.url" target="_blank" rel="noopener noreferrer">{{ project.title }}</a>
|
<a v-if="project.visibility === 'public' && project.url" class="project-link" :href="project.url" target="_blank" rel="noopener noreferrer">{{ project.title }}</a>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -109,6 +109,10 @@ canvas {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.print-portfolio-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|||||||
@@ -88,6 +88,18 @@
|
|||||||
background: #0b111b;
|
background: #0b111b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.project-link,
|
||||||
|
.contact-link,
|
||||||
|
.contact-social-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-inline-size: 44px;
|
||||||
|
min-block-size: 44px;
|
||||||
|
padding-inline: 0.55rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.site-header {
|
.site-header {
|
||||||
padding-inline: 5vw;
|
padding-inline: 5vw;
|
||||||
|
|||||||
@@ -35,6 +35,17 @@
|
|||||||
margin-block-end: 1.5rem;
|
margin-block-end: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.print-portfolio-label {
|
||||||
|
display: block;
|
||||||
|
margin-block: 0.25rem 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.portfolio-section {
|
.portfolio-section {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 0 1.25rem;
|
margin: 0 0 1.25rem;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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, type TestInfo } from '@playwright/test';
|
||||||
|
import { createServer, type ViteDevServer } from 'vite';
|
||||||
|
|
||||||
const stylesheetFiles = [
|
const stylesheetFiles = [
|
||||||
'src/styles/tokens.css',
|
'src/styles/tokens.css',
|
||||||
@@ -19,10 +20,31 @@ function informationLayerCss(): string {
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function informationLayerHarness(): string {
|
type ServerRenderer = { render: (url: string, inputs: unknown) => Promise<string> };
|
||||||
const navigation = ['Start', 'Profile', 'Experience', 'Infrastructure', 'Selected builds', 'Contact'];
|
type InputValidator = { validateProductionInputs: (content: unknown, manifest: unknown, site: unknown, preparedAssetRoot: URL) => unknown };
|
||||||
const sections = ['start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'contact'];
|
|
||||||
|
|
||||||
|
let vite: ViteDevServer;
|
||||||
|
let renderedPortfolio = '';
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
vite = await createServer({ server: { middlewareMode: true, hmr: 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(
|
||||||
|
fixture('content/public/portfolio-content.json'),
|
||||||
|
fixture('content/public/asset-manifest.json'),
|
||||||
|
fixture('config/site.preview.json'),
|
||||||
|
new URL('file:///prepared-assets/'),
|
||||||
|
);
|
||||||
|
renderedPortfolio = await render('/', inputs);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await vite.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function informationLayerHarness(): string {
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -30,32 +52,7 @@ function informationLayerHarness(): string {
|
|||||||
<style>${informationLayerCss()}</style>
|
<style>${informationLayerCss()}</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a class="skip-link" href="#portfolio-content">Skip to portfolio content</a>
|
${renderedPortfolio}
|
||||||
<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>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
@@ -68,6 +65,21 @@ async function capture(page: Page, testInfo: TestInfo, name: string): Promise<vo
|
|||||||
await page.screenshot({ path: testInfo.outputPath(name), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath(name), fullPage: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('renders the validated eight-section portfolio structure before responsive checks', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await loadInformationLayer(page);
|
||||||
|
|
||||||
|
await expect(page.locator('.portfolio-main > .portfolio-section')).toHaveCount(8);
|
||||||
|
expect(await page.locator('.portfolio-main > .portfolio-section').evaluateAll((sections) => sections.map((section) => section.id)))
|
||||||
|
.toEqual(['start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact']);
|
||||||
|
expect(await page.locator('.site-navigation a').evaluateAll((links) => links.map((link) => link.getAttribute('href'))))
|
||||||
|
.toEqual(['#start', '#profile', '#experience', '#infrastructure', '#selected-builds', '#additional-work', '#toolkit', '#contact']);
|
||||||
|
await expect(page.locator('#selected-builds article, #additional-work article')).not.toHaveCount(0);
|
||||||
|
await expect(page.locator('#toolkit [data-testid="toolkit-technology"]')).not.toHaveCount(0);
|
||||||
|
await expect(page.locator('#contact a[href^="mailto:"]')).toHaveCount(1);
|
||||||
|
await expect(page.locator('#contact a[target="_blank"]')).not.toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
for (const viewport of [
|
for (const viewport of [
|
||||||
{ width: 320, height: 800 },
|
{ width: 320, height: 800 },
|
||||||
{ width: 768, height: 1024 },
|
{ width: 768, height: 1024 },
|
||||||
@@ -139,6 +151,20 @@ test('uses a stable tablet rhythm and a single readable mobile column with usabl
|
|||||||
await capture(page, testInfo, 'information-layer-mobile.png');
|
await capture(page, testInfo, 'information-layer-mobile.png');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps actual standalone project and contact destinations at least 44 by 44px on mobile', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 320, height: 800 });
|
||||||
|
await loadInformationLayer(page);
|
||||||
|
|
||||||
|
const targets = await page.locator('#selected-builds article > a, #additional-work article > a, #contact a').evaluateAll((links) =>
|
||||||
|
links.map((link) => {
|
||||||
|
const rect = link.getBoundingClientRect();
|
||||||
|
return { href: link.getAttribute('href'), width: rect.width, height: rect.height };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(targets.length).toBeGreaterThan(0);
|
||||||
|
expect(targets.every((target) => target.width >= 44 && target.height >= 44)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('removes nonessential information-layer motion when reduced motion is requested', async ({ page }) => {
|
test('removes nonessential information-layer motion when reduced motion is requested', async ({ page }) => {
|
||||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
await loadInformationLayer(page);
|
await loadInformationLayer(page);
|
||||||
@@ -146,9 +172,13 @@ test('removes nonessential information-layer motion when reduced motion is reque
|
|||||||
expect(duration).toBe('0.001s');
|
expect(duration).toBe('0.001s');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creates a clean print portfolio with navigation removed', async ({ page }) => {
|
test('creates a clean print portfolio with a visible label but no resume packaging', async ({ page }) => {
|
||||||
await page.emulateMedia({ media: 'print' });
|
await page.emulateMedia({ media: 'print' });
|
||||||
await loadInformationLayer(page);
|
await loadInformationLayer(page);
|
||||||
await expect(page.locator('.site-navigation')).toBeHidden();
|
await expect(page.locator('.site-navigation')).toBeHidden();
|
||||||
|
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();
|
||||||
|
expect(printCopy).not.toMatch(/résumé|resume/iu);
|
||||||
|
await expect(page.locator('a[download]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user