feat: add responsive professional information layer

This commit is contained in:
2026-09-02 01:35:32 +03:00
parent f3ca9d4ade
commit 857292d2b0
11 changed files with 637 additions and 6 deletions

View File

@@ -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>
<AppShell :content="inputs.content" />
<div class="portfolio-shell">
<AppShell :content="inputs.content" />
</div>
</template>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View 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
View 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
View 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
View 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
View 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;
}

View 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 &amp; 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');
});

View File

@@ -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}'] },
},
});