feat: add responsive professional information layer
This commit is contained in:
@@ -1,10 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppShell from './components/AppShell.vue';
|
import AppShell from './components/AppShell.vue';
|
||||||
import type { ProductionInputs } from './content/types';
|
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> }>();
|
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell :content="inputs.content" />
|
<div class="portfolio-shell">
|
||||||
|
<AppShell :content="inputs.content" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a class="skip-link" href="#portfolio-content">Skip to portfolio content</a>
|
<a class="skip-link" href="#portfolio-content">Skip to portfolio content</a>
|
||||||
<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>
|
||||||
<SiteNavigation :items="canonicalNavigation" />
|
<SiteNavigation :items="canonicalNavigation" />
|
||||||
</header>
|
</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()">
|
<PortfolioSection id="start" :index="1" :eyebrow="sectionLabel('start').toUpperCase()" :heading="sectionLabel('start').toUpperCase()">
|
||||||
<p>{{ content.identity.valueProposition }}</p>
|
<p>{{ content.identity.valueProposition }}</p>
|
||||||
</PortfolioSection>
|
</PortfolioSection>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section :id="id" :aria-labelledby="`${id}-heading`" :data-section-index="index">
|
<section :id="id" class="portfolio-section" :aria-labelledby="`${id}-heading`" :data-section-index="index">
|
||||||
<p>{{ String(index).padStart(2, '0') }} / {{ eyebrow }}</p>
|
<p class="route-label">{{ String(index).padStart(2, '0') }} / {{ eyebrow }}</p>
|
||||||
<h2 :id="`${id}-heading`">{{ heading }}</h2>
|
<h2 :id="`${id}-heading`">{{ heading }}</h2>
|
||||||
<slot />
|
<slot />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ defineProps<{ items: ReadonlyArray<{ id: SectionId; label: string }> }>();
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav aria-label="Portfolio sections">
|
<nav class="site-navigation" aria-label="Portfolio sections">
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="item in items" :key="item.id">
|
<li v-for="item in items" :key="item.id">
|
||||||
<a :href="`#${item.id}`">{{ item.label }}</a>
|
<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: {
|
test: {
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
globals: false,
|
globals: false,
|
||||||
|
include: ['tests/unit/**/*.test.ts', 'tests/integration/**/*.test.{ts,mjs}'],
|
||||||
coverage: { include: ['src/**/*.{ts,vue}'] },
|
coverage: { include: ['src/**/*.{ts,vue}'] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user