feat: add responsive professional information layer
This commit is contained in:
@@ -1,10 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import AppShell from './components/AppShell.vue';
|
||||
import type { ProductionInputs } from './content/types';
|
||||
import './styles/tokens.css';
|
||||
import './styles/base.css';
|
||||
import './styles/layout.css';
|
||||
import './styles/components.css';
|
||||
import './styles/print.css';
|
||||
|
||||
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="portfolio-shell">
|
||||
<AppShell :content="inputs.content" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,13 +31,13 @@ const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id
|
||||
|
||||
<template>
|
||||
<a class="skip-link" href="#portfolio-content">Skip to portfolio content</a>
|
||||
<header>
|
||||
<header class="site-header">
|
||||
<h1>{{ content.identity.name }}</h1>
|
||||
<p>{{ content.identity.role }}</p>
|
||||
<SiteNavigation :items="canonicalNavigation" />
|
||||
</header>
|
||||
|
||||
<main id="portfolio-content">
|
||||
<main id="portfolio-content" class="portfolio-main">
|
||||
<PortfolioSection id="start" :index="1" :eyebrow="sectionLabel('start').toUpperCase()" :heading="sectionLabel('start').toUpperCase()">
|
||||
<p>{{ content.identity.valueProposition }}</p>
|
||||
</PortfolioSection>
|
||||
|
||||
@@ -8,8 +8,8 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :id="id" :aria-labelledby="`${id}-heading`" :data-section-index="index">
|
||||
<p>{{ String(index).padStart(2, '0') }} / {{ eyebrow }}</p>
|
||||
<section :id="id" class="portfolio-section" :aria-labelledby="`${id}-heading`" :data-section-index="index">
|
||||
<p class="route-label">{{ String(index).padStart(2, '0') }} / {{ eyebrow }}</p>
|
||||
<h2 :id="`${id}-heading`">{{ heading }}</h2>
|
||||
<slot />
|
||||
</section>
|
||||
|
||||
@@ -5,7 +5,7 @@ defineProps<{ items: ReadonlyArray<{ id: SectionId; label: string }> }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav aria-label="Portfolio sections">
|
||||
<nav class="site-navigation" aria-label="Portfolio sections">
|
||||
<ul>
|
||||
<li v-for="item in items" :key="item.id">
|
||||
<a :href="`#${item.id}`">{{ item.label }}</a>
|
||||
|
||||
121
src/styles/base.css
Normal file
121
src/styles/base.css
Normal file
@@ -0,0 +1,121 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 0;
|
||||
background: var(--canvas);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 100%;
|
||||
line-height: 1.55;
|
||||
scroll-behavior: auto;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--cyan);
|
||||
overflow-wrap: anywhere;
|
||||
text-decoration-color: color-mix(in srgb, currentcolor 55%, transparent);
|
||||
text-underline-offset: 0.18em;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--text);
|
||||
text-decoration-color: currentcolor;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--amber);
|
||||
color: #14100a;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p,
|
||||
dl,
|
||||
ul,
|
||||
ol,
|
||||
figure {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
color: var(--text);
|
||||
font-weight: 680;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.08;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p,
|
||||
li,
|
||||
dd,
|
||||
dt,
|
||||
a {
|
||||
max-inline-size: 72ch;
|
||||
}
|
||||
|
||||
svg,
|
||||
img,
|
||||
video,
|
||||
canvas {
|
||||
display: block;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
inset: var(--space-2) auto auto var(--space-2);
|
||||
min-block-size: 44px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
transform: translateY(-160%);
|
||||
background: var(--amber);
|
||||
color: #171006;
|
||||
font-weight: 700;
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.001s !important;
|
||||
animation-duration: 0.001s !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
131
src/styles/components.css
Normal file
131
src/styles/components.css
Normal file
@@ -0,0 +1,131 @@
|
||||
.route-label {
|
||||
color: var(--cyan);
|
||||
font-family: var(--mono);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.portfolio-section > h2 {
|
||||
margin-block: var(--space-1) var(--space-2);
|
||||
font-size: var(--step-2);
|
||||
}
|
||||
|
||||
.portfolio-section > p:not(.route-label),
|
||||
.portfolio-section article > p,
|
||||
.portfolio-section dd {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.portfolio-section > * + *,
|
||||
.portfolio-section article > * + *,
|
||||
.portfolio-section li + li {
|
||||
margin-block-start: var(--space-2);
|
||||
}
|
||||
|
||||
.portfolio-section article {
|
||||
min-width: 0;
|
||||
margin-block-start: var(--space-3);
|
||||
padding-block-start: var(--space-3);
|
||||
border-block-start: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.portfolio-section article h3,
|
||||
.portfolio-section section h3 {
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
|
||||
.portfolio-section ul,
|
||||
.portfolio-section ol {
|
||||
padding-inline-start: 1.2rem;
|
||||
}
|
||||
|
||||
.portfolio-section dl {
|
||||
display: grid;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
margin-block-start: var(--space-3);
|
||||
}
|
||||
|
||||
.portfolio-section dt {
|
||||
color: var(--cyan);
|
||||
font-family: var(--mono);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.portfolio-section dd {
|
||||
min-width: 0;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
.portfolio-section figure {
|
||||
min-width: 0;
|
||||
margin-block-start: var(--space-3);
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.portfolio-section figcaption {
|
||||
margin-block-start: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
|
||||
.portfolio-section svg {
|
||||
inline-size: 100%;
|
||||
block-size: auto;
|
||||
}
|
||||
|
||||
.portfolio-section svg rect {
|
||||
fill: #172432;
|
||||
stroke: var(--cyan);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.portfolio-section svg text {
|
||||
fill: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.portfolio-section > div {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
margin-block-start: var(--space-3);
|
||||
}
|
||||
|
||||
.portfolio-section > div > section {
|
||||
min-width: 0;
|
||||
padding-block-start: var(--space-2);
|
||||
border-block-start: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.portfolio-section > div > section ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
margin-block-start: var(--space-2);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.portfolio-section > div > section li {
|
||||
display: inline-flex;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
min-block-size: 2rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
|
||||
.portfolio-section > div > section li span[aria-hidden='true'] {
|
||||
inline-size: 0.5rem;
|
||||
block-size: 0.5rem;
|
||||
border: 1px solid var(--cyan);
|
||||
border-radius: 50%;
|
||||
}
|
||||
127
src/styles/layout.css
Normal file
127
src/styles/layout.css
Normal file
@@ -0,0 +1,127 @@
|
||||
.portfolio-shell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
padding: var(--space-4) 4vw var(--space-3);
|
||||
border-block-end: var(--panel-border);
|
||||
background: #0b111b;
|
||||
}
|
||||
|
||||
.site-header h1 {
|
||||
font-size: var(--step-3);
|
||||
}
|
||||
|
||||
.site-header > p:not(.route-label) {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
|
||||
.site-navigation {
|
||||
margin-block-start: var(--space-1);
|
||||
}
|
||||
|
||||
.site-navigation ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
max-inline-size: 78rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.site-navigation a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-block-size: 44px;
|
||||
padding-inline: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--mono);
|
||||
font-size: var(--step--1);
|
||||
letter-spacing: 0.04em;
|
||||
text-decoration: none;
|
||||
transition: color var(--transition), background-color var(--transition);
|
||||
}
|
||||
|
||||
.site-navigation a:hover,
|
||||
.site-navigation a:focus-visible {
|
||||
background: var(--cyan-deep);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.portfolio-main {
|
||||
min-width: 0;
|
||||
padding-block: var(--space-4) var(--space-5);
|
||||
}
|
||||
|
||||
.portfolio-section {
|
||||
width: calc(100% - 2rem);
|
||||
min-width: 0;
|
||||
margin: 0 1rem var(--space-4);
|
||||
padding: var(--space-3);
|
||||
scroll-margin-block: var(--space-3);
|
||||
border: var(--panel-border);
|
||||
border-radius: 2px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--panel-shadow);
|
||||
transition: border-color var(--transition), background-color var(--transition);
|
||||
}
|
||||
|
||||
.portfolio-section:hover {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.site-footer,
|
||||
.portfolio-section footer {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: var(--space-4) 4vw;
|
||||
border-block-start: var(--panel-border);
|
||||
background: #0b111b;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.site-header {
|
||||
padding-inline: 5vw;
|
||||
}
|
||||
|
||||
.portfolio-section {
|
||||
width: calc(50% - 3rem);
|
||||
margin-inline: 2rem 0;
|
||||
}
|
||||
|
||||
.portfolio-section:nth-child(even) {
|
||||
margin-inline-start: calc(50% + 1rem);
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding-inline: 5vw;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.site-header {
|
||||
padding-inline: 4vw;
|
||||
}
|
||||
|
||||
.portfolio-section {
|
||||
width: 42%;
|
||||
margin-inline: 4% 0;
|
||||
}
|
||||
|
||||
.portfolio-section:nth-child(even) {
|
||||
margin-inline-start: 54%;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding-inline: 4vw;
|
||||
}
|
||||
}
|
||||
61
src/styles/print.css
Normal file
61
src/styles/print.css
Normal file
@@ -0,0 +1,61 @@
|
||||
@media print {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--text: #121820;
|
||||
--text-muted: #374151;
|
||||
--cyan: #0e5c72;
|
||||
--surface: #fff;
|
||||
--line: #8b98a5;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.skip-link,
|
||||
.site-navigation,
|
||||
[data-decorative-world],
|
||||
[data-scene-control] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.portfolio-shell,
|
||||
.site-header,
|
||||
.portfolio-main,
|
||||
.site-footer {
|
||||
display: block;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
margin-block-end: 1.5rem;
|
||||
}
|
||||
|
||||
.portfolio-section {
|
||||
width: 100%;
|
||||
margin: 0 0 1.25rem;
|
||||
padding: 0 0 1.25rem;
|
||||
break-inside: avoid;
|
||||
border: 0;
|
||||
border-block-end: 1px solid var(--line);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a[href^='http']::after,
|
||||
a[href^='mailto:']::after {
|
||||
content: ' (' attr(href) ')';
|
||||
font-size: 0.85em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
29
src/styles/tokens.css
Normal file
29
src/styles/tokens.css
Normal file
@@ -0,0 +1,29 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--canvas: #080d16;
|
||||
--surface: #101722;
|
||||
--surface-raised: #141e2b;
|
||||
--line: #314154;
|
||||
--line-strong: #4a6275;
|
||||
--text: #f2f4f5;
|
||||
--text-muted: #b5c0c9;
|
||||
--cyan: #80d9e6;
|
||||
--cyan-deep: #163b48;
|
||||
--amber: #f5b752;
|
||||
--focus: #ffd17a;
|
||||
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
--step--1: clamp(0.82rem, 0.78rem + 0.14vw, 0.92rem);
|
||||
--step-0: clamp(1rem, 0.94rem + 0.24vw, 1.15rem);
|
||||
--step-1: clamp(1.3rem, 1.14rem + 0.68vw, 1.75rem);
|
||||
--step-2: clamp(1.9rem, 1.5rem + 1.35vw, 2.85rem);
|
||||
--step-3: clamp(2.45rem, 1.82rem + 2.25vw, 4rem);
|
||||
--space-1: clamp(0.5rem, 0.45rem + 0.2vw, 0.68rem);
|
||||
--space-2: clamp(0.85rem, 0.75rem + 0.38vw, 1.15rem);
|
||||
--space-3: clamp(1.3rem, 1.1rem + 0.7vw, 1.9rem);
|
||||
--space-4: clamp(2rem, 1.65rem + 1.1vw, 3rem);
|
||||
--space-5: clamp(3.25rem, 2.5rem + 2.6vw, 6rem);
|
||||
--panel-border: 1px solid var(--line);
|
||||
--panel-shadow: 0 14px 34px rgb(0 0 0 / 18%);
|
||||
--transition: 160ms ease;
|
||||
}
|
||||
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');
|
||||
});
|
||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
globals: false,
|
||||
include: ['tests/unit/**/*.test.ts', 'tests/integration/**/*.test.{ts,mjs}'],
|
||||
coverage: { include: ['src/**/*.{ts,vue}'] },
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user