diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index e8d26eb..ea85cf4 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -34,6 +34,7 @@ const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id diff --git a/src/components/ContactDestination.vue b/src/components/ContactDestination.vue index 3ff408a..94a2e07 100644 --- a/src/components/ContactDestination.vue +++ b/src/components/ContactDestination.vue @@ -10,11 +10,11 @@ defineProps<{ diff --git a/src/components/ProjectCard.vue b/src/components/ProjectCard.vue index 91cb181..6ee3901 100644 --- a/src/components/ProjectCard.vue +++ b/src/components/ProjectCard.vue @@ -10,6 +10,6 @@ defineProps<{ project: Readonly }>();

{{ project.summary }}

{{ project.contribution }}

{{ project.outcome }}

- {{ project.title }} + {{ project.title }} diff --git a/src/components/ProjectCaseStudy.vue b/src/components/ProjectCaseStudy.vue index f92facc..7130b8d 100644 --- a/src/components/ProjectCaseStudy.vue +++ b/src/components/ProjectCaseStudy.vue @@ -22,6 +22,6 @@ defineProps<{ project: Readonly }>();
Outcome
{{ project.outcome }}
- {{ project.title }} + {{ project.title }} diff --git a/src/styles/base.css b/src/styles/base.css index a4d911b..14a0ec8 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -109,6 +109,10 @@ canvas { transform: translateY(0); } +.print-portfolio-label { + display: none; +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/src/styles/layout.css b/src/styles/layout.css index f9e19da..30b146e 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -88,6 +88,18 @@ 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) { .site-header { padding-inline: 5vw; diff --git a/src/styles/print.css b/src/styles/print.css index fb5b8fa..69a42ad 100644 --- a/src/styles/print.css +++ b/src/styles/print.css @@ -35,6 +35,17 @@ 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 { width: 100%; margin: 0 0 1.25rem; diff --git a/tests/browser/responsive-layout.spec.ts b/tests/browser/responsive-layout.spec.ts index 0fd9fab..55ceeac 100644 --- a/tests/browser/responsive-layout.spec.ts +++ b/tests/browser/responsive-layout.spec.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { expect, test, type Page, type TestInfo } from '@playwright/test'; +import { createServer, type ViteDevServer } from 'vite'; const stylesheetFiles = [ 'src/styles/tokens.css', @@ -19,10 +20,31 @@ function informationLayerCss(): string { .join('\n'); } -function informationLayerHarness(): string { - const navigation = ['Start', 'Profile', 'Experience', 'Infrastructure', 'Selected builds', 'Contact']; - const sections = ['start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'contact']; +type ServerRenderer = { render: (url: string, inputs: unknown) => Promise }; +type InputValidator = { validateProductionInputs: (content: unknown, manifest: unknown, site: unknown, preparedAssetRoot: URL) => unknown }; +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 ` @@ -30,32 +52,7 @@ function informationLayerHarness(): string { - -
- -
- ${sections.map((id, index) => ` -
-

${String(index + 1).padStart(2, '0')} / ${id.toUpperCase()}

-

${navigation[index] ?? 'Contact'}

-

Reliable systems and software evidence for hiring managers and technical interviewers.

-
-

Operational detail

-

Long unbroken technical reference: serviceorchestrationandobservabilityintegrationwithaverylongidentifierexampletestarchitectureproductionreadiness.

- Open project reference -
-
- `).join('')} -
- -
+ ${renderedPortfolio} `; } @@ -68,6 +65,21 @@ async function capture(page: Page, testInfo: TestInfo, name: string): Promise { + 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 [ { width: 320, height: 800 }, { 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'); }); +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 }) => { await page.emulateMedia({ reducedMotion: 'reduce' }); await loadInformationLayer(page); @@ -146,9 +172,13 @@ test('removes nonessential information-layer motion when reduced motion is reque 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 loadInformationLayer(page); 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'); + const printCopy = await page.locator('body').innerText(); + expect(printCopy).not.toMatch(/résumé|resume/iu); + await expect(page.locator('a[download]')).toHaveCount(0); });