feat: surface availability and operations evidence
This commit is contained in:
@@ -224,7 +224,7 @@ onUnmounted(() => removePageListeners());
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
|
<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')">
|
<PortfolioSection id="profile" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
|
||||||
<p>{{ content.profile.introduction }}</p>
|
<p>{{ content.profile.introduction }}</p>
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ defineProps<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="contact-destination">
|
<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>
|
<p class="contact-destination__copy">{{ contact.introduction }}</p>
|
||||||
<a class="contact-link" :href="`mailto:${contact.email}`">Start a conversation</a>
|
<a class="contact-link" :href="`mailto:${contact.email}`">Email Luca</a>
|
||||||
<p class="contact-destination__email">{{ contact.email }}</p>
|
<p class="contact-destination__email">{{ contact.email }}</p>
|
||||||
<p class="contact-destination__cv">{{ contact.cvMessage }}</p>
|
<p class="contact-destination__cv">{{ contact.cvMessage }}</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="contact-social">
|
<ul class="contact-social">
|
||||||
<li v-for="link in contact.social" :key="link.service">
|
<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" />
|
<BrandIcon :label="link.service" />
|
||||||
<span>{{ link.service }}</span>
|
<span>{{ link.service }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { PortfolioContent } from '@/content/types';
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
identity: Readonly<PortfolioContent['identity']>;
|
identity: Readonly<PortfolioContent['identity']>;
|
||||||
|
email: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -16,7 +17,11 @@ const emit = defineEmits<{
|
|||||||
<h1 id="start-heading" tabindex="-1">{{ identity.name }}</h1>
|
<h1 id="start-heading" tabindex="-1">{{ identity.name }}</h1>
|
||||||
<p class="hero-journey__role">{{ identity.role }}</p>
|
<p class="hero-journey__role">{{ identity.role }}</p>
|
||||||
<p class="hero-journey__value">{{ identity.valueProposition }}</p>
|
<p class="hero-journey__value">{{ identity.valueProposition }}</p>
|
||||||
<a class="hero-journey__action" href="#selected-builds" @click="emit('explore', $event)">Explore work</a>
|
<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>
|
</div>
|
||||||
<p class="print-portfolio-label" aria-hidden="true">Portfolio</p>
|
<p class="print-portfolio-label" aria-hidden="true">Portfolio</p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import OperationsProof from './OperationsProof.vue';
|
||||||
import type { InfrastructureCaseStudy } from "@/content/types";
|
import type { InfrastructureCaseStudy } from "@/content/types";
|
||||||
|
|
||||||
defineProps<{ infrastructure: Readonly<InfrastructureCaseStudy> }>();
|
defineProps<{ infrastructure: Readonly<InfrastructureCaseStudy> }>();
|
||||||
@@ -118,5 +119,6 @@ defineProps<{ infrastructure: Readonly<InfrastructureCaseStudy> }>();
|
|||||||
>: {{ band.summary }}
|
>: {{ band.summary }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<OperationsProof id="nextcloud-migration" :operation="infrastructure.featuredOperation" />
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
16
src/components/OperationsProof.vue
Normal file
16
src/components/OperationsProof.vue
Normal 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>
|
||||||
@@ -23,6 +23,19 @@
|
|||||||
font-size: var(--step-0);
|
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,
|
.hero-journey__action,
|
||||||
.project-link,
|
.project-link,
|
||||||
.contact-link,
|
.contact-link,
|
||||||
@@ -43,6 +56,17 @@
|
|||||||
text-decoration: none;
|
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 {
|
.hero-journey__action::after {
|
||||||
content: "→";
|
content: "→";
|
||||||
transition: transform var(--transition);
|
transition: transform var(--transition);
|
||||||
@@ -189,6 +213,50 @@
|
|||||||
background: rgb(5 12 23 / 90%);
|
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 {
|
.portfolio-section[data-layout="full-diagram"] figure {
|
||||||
background: rgb(10 24 38 / 72%);
|
background: rgb(10 24 38 / 72%);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@
|
|||||||
|
|
||||||
.hero-journey__content {
|
.hero-journey__content {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
min-inline-size: 0;
|
||||||
inline-size: min(34rem, 39vw);
|
inline-size: min(34rem, 39vw);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: clamp(1.4rem, 2.2vw, 2.1rem);
|
padding: clamp(1.4rem, 2.2vw, 2.1rem);
|
||||||
|
|||||||
@@ -50,6 +50,20 @@
|
|||||||
margin-block-start: 0.4rem;
|
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 {
|
.print-portfolio-label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-block: 0.25rem 1rem;
|
margin-block: 0.25rem 1rem;
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ test("tabs from the skip link through navigation and every contact action", asyn
|
|||||||
const expectedTargets = [
|
const expectedTargets = [
|
||||||
{ selector: ".skip-link", href: "#portfolio-content" },
|
{ selector: ".skip-link", href: "#portfolio-content" },
|
||||||
...navHrefs.map((href) => ({ selector: `nav a[href="${href}"]`, href })),
|
...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"]',
|
selector: 'a[href="https://github.com/LucaPisl/footy-tracker"]',
|
||||||
href: "https://github.com/LucaPisl/footy-tracker",
|
href: "https://github.com/LucaPisl/footy-tracker",
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ const expectedVisibleTextSegments = (
|
|||||||
content.identity.name,
|
content.identity.name,
|
||||||
content.identity.role,
|
content.identity.role,
|
||||||
content.identity.valueProposition,
|
content.identity.valueProposition,
|
||||||
|
content.identity.availability,
|
||||||
|
"Email Luca",
|
||||||
"Explore work",
|
"Explore work",
|
||||||
...section("profile", [
|
...section("profile", [
|
||||||
content.profile.introduction,
|
content.profile.introduction,
|
||||||
@@ -140,6 +142,11 @@ const expectedVisibleTextSegments = (
|
|||||||
...content.infrastructure.bands.map(
|
...content.infrastructure.bands.map(
|
||||||
(band) => `${band.title}: ${band.summary}`,
|
(band) => `${band.title}: ${band.summary}`,
|
||||||
),
|
),
|
||||||
|
content.infrastructure.featuredOperation.title,
|
||||||
|
content.infrastructure.featuredOperation.context,
|
||||||
|
...content.infrastructure.featuredOperation.actions,
|
||||||
|
"Outcome:",
|
||||||
|
content.infrastructure.featuredOperation.outcome,
|
||||||
]),
|
]),
|
||||||
...section(
|
...section(
|
||||||
"selected-builds",
|
"selected-builds",
|
||||||
@@ -178,8 +185,8 @@ const expectedVisibleTextSegments = (
|
|||||||
...section(
|
...section(
|
||||||
"contact",
|
"contact",
|
||||||
[
|
[
|
||||||
"Thanks for making the trip. The road's open if you'd like to build something together.",
|
content.contact.introduction,
|
||||||
"Start a conversation",
|
"Email Luca",
|
||||||
content.contact.email,
|
content.contact.email,
|
||||||
content.contact.cvMessage,
|
content.contact.cvMessage,
|
||||||
...content.contact.social.map((link) => link.service),
|
...content.contact.social.map((link) => link.service),
|
||||||
|
|||||||
@@ -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.locator('.route-label')).toHaveCount(0);
|
||||||
await expect(page.getByRole('link', { name: 'Explore work' })).toHaveAttribute('href', '#selected-builds');
|
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(
|
expect(
|
||||||
await page.locator('h1, h2, h3').evaluateAll((headings) => {
|
await page.locator('h1, h2, h3').evaluateAll((headings) => {
|
||||||
const levels = headings.map((heading) =>
|
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:"]');
|
const email = page.locator('a[href^="mailto:"]');
|
||||||
await expect(email).toHaveCount(1);
|
await expect(email).toHaveCount(2);
|
||||||
await expect(email).toHaveText('Start a conversation');
|
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"]');
|
const externalLinks = page.locator('#contact a[target="_blank"]');
|
||||||
await expect(externalLinks).toHaveCount(3);
|
await expect(externalLinks).toHaveCount(3);
|
||||||
@@ -162,6 +166,7 @@ test('keeps canonical native anchors and safe external destinations', async ({
|
|||||||
return (
|
return (
|
||||||
link instanceof HTMLAnchorElement &&
|
link instanceof HTMLAnchorElement &&
|
||||||
link.protocol === 'https:' &&
|
link.protocol === 'https:' &&
|
||||||
|
rel.has('me') &&
|
||||||
rel.has('noopener') &&
|
rel.has('noopener') &&
|
||||||
rel.has('noreferrer')
|
rel.has('noreferrer')
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,15 +53,23 @@ describe('semantic portfolio document', () => {
|
|||||||
const document = await renderedDocument();
|
const document = await renderedDocument();
|
||||||
const hero = document.querySelector('.hero-journey__content')!;
|
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',
|
'Luca Pîslaru',
|
||||||
'System Administrator & Full-Stack Developer',
|
'System Administrator & Full-Stack Developer',
|
||||||
'Building and operating reliable systems and software.',
|
validInputs.content.identity.valueProposition,
|
||||||
|
validInputs.content.identity.availability,
|
||||||
|
'Email Luca',
|
||||||
'Explore work',
|
'Explore work',
|
||||||
]);
|
]);
|
||||||
expect(hero.querySelector('a')?.getAttribute('href')).toBe('#selected-builds');
|
expect(hero.querySelector('a[href="mailto:hello@example.test"]')?.textContent).toBe('Email Luca');
|
||||||
expect(document.querySelector('a[href="mailto:hello@example.test"]')?.textContent).toBe('Start a conversation');
|
expect(document.querySelector('[data-featured-operation]')?.textContent).toContain(
|
||||||
expect(document.body.textContent).toContain("Thanks for making the trip. The road's open if you'd like to build something together.");
|
validInputs.content.infrastructure.featuredOperation.title,
|
||||||
|
);
|
||||||
|
expect(document.querySelector('#contact')?.textContent).toContain('System Administrator');
|
||||||
expect(document.body.textContent).not.toMatch(/[–—]/u);
|
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',
|
'#start', '#profile', '#experience', '#infrastructure', '#selected-builds', '#additional-work', '#toolkit', '#contact',
|
||||||
]);
|
]);
|
||||||
const email = document.querySelector('a[href="mailto:hello@example.test"]');
|
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.');
|
expect(document.body.textContent).toContain('For a current CV or role-specific information, contact me directly.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -178,13 +178,11 @@ describe("semantic section components", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(wrapper.get('a[href="mailto:hello@example.test"]').text()).toBe(
|
expect(wrapper.get('a[href="mailto:hello@example.test"]').text()).toBe(
|
||||||
"Start a conversation",
|
"Email Luca",
|
||||||
);
|
|
||||||
expect(wrapper.text()).toContain(
|
|
||||||
"Thanks for making the trip. The road's open if you'd like to build something together.",
|
|
||||||
);
|
);
|
||||||
|
expect(wrapper.text()).toContain(contact.introduction);
|
||||||
const social = wrapper.get('a[href="https://github.com/example"]');
|
const social = wrapper.get('a[href="https://github.com/example"]');
|
||||||
expect(social.text()).toBe("GitHub");
|
expect(social.text()).toBe("GitHub");
|
||||||
expect(social.attributes("rel")).toBe("noopener noreferrer");
|
expect(social.attributes("rel")).toBe("me noopener noreferrer");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user