feat: surface availability and operations evidence

This commit is contained in:
2026-09-04 20:34:19 +03:00
parent 3509f5fbdf
commit 55158d490e
13 changed files with 147 additions and 22 deletions

View File

@@ -224,7 +224,7 @@ onUnmounted(() => removePageListeners());
</header>
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
<HeroJourney :identity="content.identity" @explore="navigation.onSectionLinkClick" />
<HeroJourney :identity="content.identity" :email="content.contact.email" @explore="navigation.onSectionLinkClick" />
<PortfolioSection id="profile" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
<p>{{ content.profile.introduction }}</p>

View File

@@ -9,14 +9,14 @@ defineProps<{
<template>
<div class="contact-destination">
<p class="contact-destination__copy">Thanks for making the trip. The road's open if you'd like to build something together.</p>
<a class="contact-link" :href="`mailto:${contact.email}`">Start a conversation</a>
<p class="contact-destination__copy">{{ contact.introduction }}</p>
<a class="contact-link" :href="`mailto:${contact.email}`">Email Luca</a>
<p class="contact-destination__email">{{ contact.email }}</p>
<p class="contact-destination__cv">{{ contact.cvMessage }}</p>
</div>
<ul class="contact-social">
<li v-for="link in contact.social" :key="link.service">
<a class="contact-social-link" :href="link.url" target="_blank" rel="noopener noreferrer">
<a class="contact-social-link" :href="link.url" target="_blank" rel="me noopener noreferrer">
<BrandIcon :label="link.service" />
<span>{{ link.service }}</span>
</a>

View File

@@ -3,6 +3,7 @@ import type { PortfolioContent } from '@/content/types';
defineProps<{
identity: Readonly<PortfolioContent['identity']>;
email: string;
}>();
const emit = defineEmits<{
@@ -16,8 +17,12 @@ const emit = defineEmits<{
<h1 id="start-heading" tabindex="-1">{{ identity.name }}</h1>
<p class="hero-journey__role">{{ identity.role }}</p>
<p class="hero-journey__value">{{ identity.valueProposition }}</p>
<p class="hero-journey__availability">{{ identity.availability }}</p>
<div class="hero-journey__actions">
<a class="hero-journey__action hero-journey__action--primary" :href="`mailto:${email}`">Email Luca</a>
<a class="hero-journey__action" href="#selected-builds" @click="emit('explore', $event)">Explore work</a>
</div>
</div>
<p class="print-portfolio-label" aria-hidden="true">Portfolio</p>
</section>
</template>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import OperationsProof from './OperationsProof.vue';
import type { InfrastructureCaseStudy } from "@/content/types";
defineProps<{ infrastructure: Readonly<InfrastructureCaseStudy> }>();
@@ -118,5 +119,6 @@ defineProps<{ infrastructure: Readonly<InfrastructureCaseStudy> }>();
>: {{ band.summary }}
</li>
</ul>
<OperationsProof id="nextcloud-migration" :operation="infrastructure.featuredOperation" />
</article>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { FeaturedOperation } from '@/content/types';
defineProps<{ id: string; operation: Readonly<FeaturedOperation> }>();
</script>
<template>
<section class="operations-proof" data-featured-operation :aria-labelledby="`${id}-heading`">
<h4 :id="`${id}-heading`">{{ operation.title }}</h4>
<p>{{ operation.context }}</p>
<ol>
<li v-for="action in operation.actions" :key="action">{{ action }}</li>
</ol>
<p class="operations-proof__outcome"><strong>Outcome:</strong> {{ operation.outcome }}</p>
</section>
</template>

View File

@@ -23,6 +23,19 @@
font-size: var(--step-0);
}
.hero-journey__availability {
max-inline-size: 52ch;
color: var(--text-muted);
font-size: var(--step--1);
}
.hero-journey__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
align-items: center;
}
.hero-journey__action,
.project-link,
.contact-link,
@@ -43,6 +56,17 @@
text-decoration: none;
}
.hero-journey__action--primary {
padding-inline: 0.9rem;
border: 1px solid var(--cyan);
background: var(--cyan);
color: #041017;
}
.hero-journey__action--primary::after {
content: none;
}
.hero-journey__action::after {
content: "→";
transition: transform var(--transition);
@@ -189,6 +213,50 @@
background: rgb(5 12 23 / 90%);
}
.operations-proof {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: var(--space-2) var(--space-3);
margin-block-start: var(--space-3);
padding-block-start: var(--space-3);
border-block-start: 2px solid var(--line-strong);
}
.operations-proof > h4,
.operations-proof__outcome {
grid-column: 1 / -1;
margin: 0;
}
.operations-proof > h4 {
color: var(--text);
font-size: var(--step-0);
}
.operations-proof > p:not(.operations-proof__outcome),
.operations-proof > ol {
margin: 0;
}
.operations-proof > ol {
padding-inline-start: 1.2rem;
}
.operations-proof__outcome {
color: var(--text) !important;
}
@media (max-width: 48rem) {
.operations-proof {
grid-template-columns: minmax(0, 1fr);
}
.operations-proof > h4,
.operations-proof__outcome {
grid-column: auto;
}
}
.portfolio-section[data-layout="full-diagram"] figure {
background: rgb(10 24 38 / 72%);
}

View File

@@ -85,6 +85,7 @@
.hero-journey__content {
display: grid;
min-inline-size: 0;
inline-size: min(34rem, 39vw);
gap: 1rem;
padding: clamp(1.4rem, 2.2vw, 2.1rem);

View File

@@ -50,6 +50,20 @@
margin-block-start: 0.4rem;
}
.hero-journey__actions,
.operations-proof {
display: block;
}
.operations-proof {
margin-block-start: 1rem;
padding-block-start: 1rem;
}
.operations-proof > * + * {
margin-block-start: 0.6rem;
}
.print-portfolio-label {
display: block;
margin-block: 0.25rem 1rem;

View File

@@ -55,7 +55,8 @@ test("tabs from the skip link through navigation and every contact action", asyn
const expectedTargets = [
{ selector: ".skip-link", href: "#portfolio-content" },
...navHrefs.map((href) => ({ selector: `nav a[href="${href}"]`, href })),
{ selector: ".hero-journey__action", href: "#selected-builds" },
{ selector: '.hero-journey__action--primary', href: 'mailto:luca.pislaru@gmail.com' },
{ selector: '.hero-journey__actions a[href="#selected-builds"]', href: "#selected-builds" },
{
selector: 'a[href="https://github.com/LucaPisl/footy-tracker"]',
href: "https://github.com/LucaPisl/footy-tracker",

View File

@@ -110,6 +110,8 @@ const expectedVisibleTextSegments = (
content.identity.name,
content.identity.role,
content.identity.valueProposition,
content.identity.availability,
"Email Luca",
"Explore work",
...section("profile", [
content.profile.introduction,
@@ -140,6 +142,11 @@ const expectedVisibleTextSegments = (
...content.infrastructure.bands.map(
(band) => `${band.title}: ${band.summary}`,
),
content.infrastructure.featuredOperation.title,
content.infrastructure.featuredOperation.context,
...content.infrastructure.featuredOperation.actions,
"Outcome:",
content.infrastructure.featuredOperation.outcome,
]),
...section(
"selected-builds",
@@ -178,8 +185,8 @@ const expectedVisibleTextSegments = (
...section(
"contact",
[
"Thanks for making the trip. The road's open if you'd like to build something together.",
"Start a conversation",
content.contact.introduction,
"Email Luca",
content.contact.email,
content.contact.cvMessage,
...content.contact.social.map((link) => link.service),

View File

@@ -100,7 +100,8 @@ test('exposes the complete semantic portfolio from the generated document', asyn
);
await expect(page.locator('.route-label')).toHaveCount(0);
await expect(page.getByRole('link', { name: 'Explore work' })).toHaveAttribute('href', '#selected-builds');
await expect(page.getByRole('link', { name: 'Start a conversation' })).toHaveAttribute('href', 'mailto:luca.pislaru@gmail.com');
await expect(page.getByRole('link', { name: 'Email Luca' })).toHaveCount(2);
await expect(page.locator('[data-featured-operation]')).toContainText('without data loss');
expect(
await page.locator('h1, h2, h3').evaluateAll((headings) => {
const levels = headings.map((heading) =>
@@ -150,8 +151,11 @@ test('keeps canonical native anchors and safe external destinations', async ({
}
const email = page.locator('a[href^="mailto:"]');
await expect(email).toHaveCount(1);
await expect(email).toHaveText('Start a conversation');
await expect(email).toHaveCount(2);
await expect(email).toHaveText(['Email Luca', 'Email Luca']);
expect(await email.evaluateAll((links) => new Set(links.map((link) => link.getAttribute('href'))))).toEqual(
new Set(['mailto:luca.pislaru@gmail.com']),
);
const externalLinks = page.locator('#contact a[target="_blank"]');
await expect(externalLinks).toHaveCount(3);
@@ -162,6 +166,7 @@ test('keeps canonical native anchors and safe external destinations', async ({
return (
link instanceof HTMLAnchorElement &&
link.protocol === 'https:' &&
rel.has('me') &&
rel.has('noopener') &&
rel.has('noreferrer')
);

View File

@@ -53,15 +53,23 @@ describe('semantic portfolio document', () => {
const document = await renderedDocument();
const hero = document.querySelector('.hero-journey__content')!;
expect([...hero.children].map((element) => element.textContent?.trim())).toEqual([
expect([...hero.children].flatMap((element) =>
element.classList.contains('hero-journey__actions')
? [...element.children].map((action) => action.textContent?.trim())
: [element.textContent?.trim()],
)).toEqual([
'Luca Pîslaru',
'System Administrator & Full-Stack Developer',
'Building and operating reliable systems and software.',
validInputs.content.identity.valueProposition,
validInputs.content.identity.availability,
'Email Luca',
'Explore work',
]);
expect(hero.querySelector('a')?.getAttribute('href')).toBe('#selected-builds');
expect(document.querySelector('a[href="mailto:hello@example.test"]')?.textContent).toBe('Start a conversation');
expect(document.body.textContent).toContain("Thanks for making the trip. The road's open if you'd like to build something together.");
expect(hero.querySelector('a[href="mailto:hello@example.test"]')?.textContent).toBe('Email Luca');
expect(document.querySelector('[data-featured-operation]')?.textContent).toContain(
validInputs.content.infrastructure.featuredOperation.title,
);
expect(document.querySelector('#contact')?.textContent).toContain('System Administrator');
expect(document.body.textContent).not.toMatch(/[–—]/u);
});
@@ -87,7 +95,7 @@ describe('semantic portfolio document', () => {
'#start', '#profile', '#experience', '#infrastructure', '#selected-builds', '#additional-work', '#toolkit', '#contact',
]);
const email = document.querySelector('a[href="mailto:hello@example.test"]');
expect(email?.textContent).toBe('Start a conversation');
expect(email?.textContent).toBe('Email Luca');
expect(document.body.textContent).toContain('For a current CV or role-specific information, contact me directly.');
});

View File

@@ -178,13 +178,11 @@ describe("semantic section components", () => {
});
expect(wrapper.get('a[href="mailto:hello@example.test"]').text()).toBe(
"Start a conversation",
);
expect(wrapper.text()).toContain(
"Thanks for making the trip. The road's open if you'd like to build something together.",
"Email Luca",
);
expect(wrapper.text()).toContain(contact.introduction);
const social = wrapper.get('a[href="https://github.com/example"]');
expect(social.text()).toBe("GitHub");
expect(social.attributes("rel")).toBe("noopener noreferrer");
expect(social.attributes("rel")).toBe("me noopener noreferrer");
});
});