feat: compose the portfolio journey layer

This commit is contained in:
2026-09-02 16:21:13 +03:00
parent 7e48aad6a8
commit 8c17af22a0
26 changed files with 962 additions and 368 deletions

View File

@@ -17,9 +17,9 @@
"profile": { "profile": {
"introduction": "I work across Linux infrastructure and full-stack delivery, pairing practical operations with software built to be understood, maintained, and improved over time.", "introduction": "I work across Linux infrastructure and full-stack delivery, pairing practical operations with software built to be understood, maintained, and improved over time.",
"screeningFacts": [ "screeningFacts": [
{ "label": "Education", "value": "Bachelor's degree in Business Management IT, Romanian-American University, 2023–2026" }, { "label": "Education", "value": "Bachelor's degree in Business Management IT, Romanian-American University, 2023-2026" },
{ "label": "Languages", "value": "Romanian (native), English (C2), French (A1)" }, { "label": "Languages", "value": "Romanian (native), English (C2), French (A1)" },
{ "label": "Certification", "value": "PCEP — Certified Entry-Level Python Programmer, 2022" } { "label": "Certification", "value": "PCEP - Certified Entry-Level Python Programmer, 2022" }
] ]
}, },
"experience": [ "experience": [
@@ -27,7 +27,7 @@
"id": "mihai-razus-pfa", "id": "mihai-razus-pfa",
"role": "Full-Stack Developer & DevOps collaborator", "role": "Full-Stack Developer & DevOps collaborator",
"organization": "Mihai Razus PFA", "organization": "Mihai Razus PFA",
"dates": "Aug 2025 – Present", "dates": "Aug 2025 - Present",
"highlights": [ "highlights": [
"Contribute to full-stack delivery across application and deployment work.", "Contribute to full-stack delivery across application and deployment work.",
"Support practical DevOps collaboration alongside software development." "Support practical DevOps collaboration alongside software development."
@@ -37,7 +37,7 @@
"id": "camera-consultantilor-fiscali", "id": "camera-consultantilor-fiscali",
"role": "Green Innovation Intern", "role": "Green Innovation Intern",
"organization": "Camera Consultanților Fiscali", "organization": "Camera Consultanților Fiscali",
"dates": "Jun 2025 – Aug 2025", "dates": "Jun 2025 - Aug 2025",
"highlights": [ "highlights": [
"Contributed to green innovation work in an internship setting.", "Contributed to green innovation work in an internship setting.",
"Applied structured problem-solving and digital skills to assigned work." "Applied structured problem-solving and digital skills to assigned work."
@@ -47,7 +47,7 @@
"id": "isense-solutions", "id": "isense-solutions",
"role": "Data Science Intern", "role": "Data Science Intern",
"organization": "iSense Solutions", "organization": "iSense Solutions",
"dates": "Jun 2025 – Aug 2025", "dates": "Jun 2025 - Aug 2025",
"highlights": [ "highlights": [
"Contributed to data science work in an internship setting.", "Contributed to data science work in an internship setting.",
"Applied analytical and programming skills to assigned tasks." "Applied analytical and programming skills to assigned tasks."

View File

@@ -213,10 +213,19 @@ const assertSemanticHtml = (html, inputs, contract) => {
} }
for (const section of sections) { for (const section of sections) {
const label = inputs.content.navigation.find((item) => item.id === section.id)?.label; const label = inputs.content.navigation.find((item) => item.id === section.id)?.label;
if (section.id === 'start') {
const heroHeadings = [...section.querySelectorAll(':scope h1')]
.filter((node) => isVisibleSemanticNode(node, appRoot));
if (heroHeadings.length !== 1 || heroHeadings[0].id !== 'start-heading' ||
heroHeadings[0].textContent !== inputs.content.identity.name) {
throw new Error('Missing visible hero heading: start');
}
continue;
}
const headings = [...section.querySelectorAll(':scope > h2')] const headings = [...section.querySelectorAll(':scope > h2')]
.filter((node) => isVisibleSemanticNode(node, appRoot)); .filter((node) => isVisibleSemanticNode(node, appRoot));
if (label === undefined || headings.length !== 1 || headings[0].id !== `${section.id}-heading` || if (label === undefined || headings.length !== 1 || headings[0].id !== `${section.id}-heading` ||
headings[0].textContent !== label.toUpperCase()) { headings[0].textContent !== label) {
throw new Error(`Missing visible section heading: ${section.id}`); throw new Error(`Missing visible section heading: ${section.id}`);
} }
} }
@@ -235,7 +244,7 @@ const assertSemanticHtml = (html, inputs, contract) => {
} }
const mailLinks = [...appRoot.querySelectorAll(`a[href="mailto:${inputs.content.contact.email}"]`)] const mailLinks = [...appRoot.querySelectorAll(`a[href="mailto:${inputs.content.contact.email}"]`)]
.filter((node) => isVisibleSemanticNode(node, appRoot)); .filter((node) => isVisibleSemanticNode(node, appRoot));
if (mailLinks.length !== 1 || mailLinks[0].textContent !== inputs.content.contact.email) { if (mailLinks.length !== 1 || mailLinks[0].textContent !== 'Start a conversation') {
throw new Error('Missing visible semantic mailto contact link.'); throw new Error('Missing visible semantic mailto contact link.');
} }

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue';
import ContactDestination from './ContactDestination.vue'; import ContactDestination from './ContactDestination.vue';
import DriveControls from './DriveControls.vue'; import DriveControls from './DriveControls.vue';
import ExperienceTimeline from './ExperienceTimeline.vue'; import ExperienceTimeline from './ExperienceTimeline.vue';
import HeroJourney from './HeroJourney.vue';
import InfrastructureDiagram from './InfrastructureDiagram.vue'; import InfrastructureDiagram from './InfrastructureDiagram.vue';
import JourneyHud from './JourneyHud.vue'; import JourneyHud from './JourneyHud.vue';
import PortfolioSection from './PortfolioSection.vue'; import PortfolioSection from './PortfolioSection.vue';
@@ -16,7 +17,7 @@ import { useAudioEngine } from '@/composables/useAudioEngine';
import { useJourneySections } from '@/composables/useJourneySections'; import { useJourneySections } from '@/composables/useJourneySections';
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation'; import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
import { useMotionPreferences } from '@/composables/useMotionPreferences'; import { useMotionPreferences } from '@/composables/useMotionPreferences';
import type { PortfolioContent, SectionId } from '@/content/types'; import type { PortfolioContent, SectionId, SectionLayout } from '@/content/types';
import type { SceneHostState } from '@/composables/useSceneController'; import type { SceneHostState } from '@/composables/useSceneController';
import type { MotionPolicy } from '@/journey/motion-policy'; import type { MotionPolicy } from '@/journey/motion-policy';
@@ -29,6 +30,17 @@ const canonicalSectionIds = [
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact', 'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
] as const satisfies readonly SectionId[]; ] as const satisfies readonly SectionId[];
const sectionLayouts = {
start: 'hero',
profile: 'right-rail',
experience: 'left-timeline',
infrastructure: 'full-diagram',
'selected-builds': 'project-stage',
'additional-work': 'compact-rail',
toolkit: 'toolkit-field',
contact: 'destination',
} as const satisfies Readonly<Record<SectionId, SectionLayout>>;
const navigationById = computed(() => new Map(props.content.navigation.map((item) => [item.id, item]))); const navigationById = computed(() => new Map(props.content.navigation.map((item) => [item.id, item])));
const sectionLabel = (id: SectionId): string => { const sectionLabel = (id: SectionId): string => {
@@ -174,9 +186,6 @@ onUnmounted(() => removePageListeners());
:section-label="activeSectionLabel" :section-label="activeSectionLabel"
/> />
<header class="site-header"> <header class="site-header">
<h1>{{ content.identity.name }}</h1>
<p>{{ content.identity.role }}</p>
<p class="print-portfolio-label" aria-hidden="true">Portfolio</p>
<SiteNavigation <SiteNavigation
:items="canonicalNavigation" :items="canonicalNavigation"
:journey="journey" :journey="journey"
@@ -185,13 +194,11 @@ onUnmounted(() => removePageListeners());
</header> </header>
<main id="portfolio-content" class="portfolio-main" tabindex="-1"> <main id="portfolio-content" class="portfolio-main" tabindex="-1">
<PortfolioSection id="start" :index="1" :eyebrow="sectionLabel('start').toUpperCase()" :heading="sectionLabel('start').toUpperCase()"> <HeroJourney :identity="content.identity" @explore="navigation.onSectionLinkClick" />
<p>{{ content.identity.valueProposition }}</p>
</PortfolioSection>
<PortfolioSection id="profile" :index="2" :eyebrow="sectionLabel('profile').toUpperCase()" :heading="sectionLabel('profile').toUpperCase()"> <PortfolioSection id="profile" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
<p>{{ content.profile.introduction }}</p> <p>{{ content.profile.introduction }}</p>
<dl> <dl class="profile-facts">
<template v-for="fact in content.profile.screeningFacts" :key="fact.label"> <template v-for="fact in content.profile.screeningFacts" :key="fact.label">
<dt>{{ fact.label }}</dt> <dt>{{ fact.label }}</dt>
<dd>{{ fact.value }}</dd> <dd>{{ fact.value }}</dd>
@@ -199,39 +206,44 @@ onUnmounted(() => removePageListeners());
</dl> </dl>
</PortfolioSection> </PortfolioSection>
<PortfolioSection id="experience" :index="3" :eyebrow="sectionLabel('experience').toUpperCase()" :heading="sectionLabel('experience').toUpperCase()"> <PortfolioSection id="experience" :layout="sectionLayouts.experience" :heading="sectionLabel('experience')">
<ExperienceTimeline :experience="content.experience" :systems-operations="content.systemsOperations" /> <ExperienceTimeline :experience="content.experience" :systems-operations="content.systemsOperations" />
</PortfolioSection> </PortfolioSection>
<PortfolioSection id="infrastructure" :index="4" :eyebrow="sectionLabel('infrastructure').toUpperCase()" :heading="sectionLabel('infrastructure').toUpperCase()"> <PortfolioSection id="infrastructure" :layout="sectionLayouts.infrastructure" :heading="sectionLabel('infrastructure')">
<InfrastructureDiagram :infrastructure="content.infrastructure" /> <InfrastructureDiagram :infrastructure="content.infrastructure" />
</PortfolioSection> </PortfolioSection>
<PortfolioSection id="selected-builds" :index="5" :eyebrow="sectionLabel('selected-builds').toUpperCase()" :heading="sectionLabel('selected-builds').toUpperCase()"> <PortfolioSection id="selected-builds" :layout="sectionLayouts['selected-builds']" eyebrow="Selected work" :heading="sectionLabel('selected-builds')">
<ProjectCaseStudy <div class="project-stage__projects">
v-for="project in content.projects.selected" <ProjectCaseStudy
:key="project.id" v-for="(project, index) in content.projects.selected"
:project="project" :key="project.id"
:allow-automatic-video="motion.policy.value.allowAutomaticVideo" :project="project"
/> :priority="index < 2 ? 'primary' : 'supporting'"
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
/>
</div>
</PortfolioSection> </PortfolioSection>
<PortfolioSection id="additional-work" :index="6" :eyebrow="sectionLabel('additional-work').toUpperCase()" :heading="sectionLabel('additional-work').toUpperCase()"> <PortfolioSection id="additional-work" :layout="sectionLayouts['additional-work']" :heading="sectionLabel('additional-work')">
<ProjectCard <div class="compact-projects">
v-for="project in content.projects.additional" <ProjectCard
:key="project.id" v-for="project in content.projects.additional"
:project="project" :key="project.id"
:allow-automatic-video="motion.policy.value.allowAutomaticVideo" :project="project"
/> :allow-automatic-video="motion.policy.value.allowAutomaticVideo"
/>
</div>
</PortfolioSection> </PortfolioSection>
<PortfolioSection id="toolkit" :index="7" :eyebrow="sectionLabel('toolkit').toUpperCase()" :heading="sectionLabel('toolkit').toUpperCase()"> <PortfolioSection id="toolkit" :layout="sectionLayouts.toolkit" :heading="sectionLabel('toolkit')">
<ToolkitGrid :groups="content.toolkit" /> <ToolkitGrid :groups="content.toolkit" />
</PortfolioSection> </PortfolioSection>
<PortfolioSection id="contact" :index="8" :eyebrow="sectionLabel('contact').toUpperCase()" :heading="sectionLabel('contact').toUpperCase()"> <PortfolioSection id="contact" :layout="sectionLayouts.contact" eyebrow="Destination" :heading="sectionLabel('contact')">
<footer> <footer>
<ContactDestination :contact="content.contact" :identity="content.identity" /> <ContactDestination :contact="content.contact" />
</footer> </footer>
</PortfolioSection> </PortfolioSection>
</main> </main>

View File

@@ -1,18 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ContactContent, PortfolioContent } from '@/content/types'; import type { ContactContent } from '@/content/types';
defineProps<{ defineProps<{
contact: Readonly<ContactContent>; contact: Readonly<ContactContent>;
identity: Readonly<Pick<PortfolioContent['identity'], 'name' | 'role'>>;
}>(); }>();
</script> </script>
<template> <template>
<p>{{ identity.name }}</p> <div class="contact-destination">
<p>{{ identity.role }}</p> <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}`">{{ contact.email }}</a> <a class="contact-link" :href="`mailto:${contact.email}`">Start a conversation</a>
<p>{{ contact.cvMessage }}</p> <p class="contact-destination__email">{{ contact.email }}</p>
<ul> <p class="contact-destination__cv">{{ contact.cvMessage }}</p>
</div>
<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">{{ link.service }}</a> <a class="contact-social-link" :href="link.url" target="_blank" rel="noopener noreferrer">{{ link.service }}</a>
</li> </li>

View File

@@ -8,11 +8,11 @@ defineProps<{
</script> </script>
<template> <template>
<ol> <ol class="experience-timeline">
<li v-for="record in experience" :key="record.id"> <li v-for="record in experience" :key="record.id">
<article> <article class="experience-record">
<h3>{{ record.role }}</h3> <h3>{{ record.role }}</h3>
<p>{{ record.organization }} · {{ record.dates }}<template v-if="record.location"> · {{ record.location }}</template></p> <p class="experience-record__meta">{{ record.organization }} · {{ record.dates }}<template v-if="record.location"> · {{ record.location }}</template></p>
<ul> <ul>
<li v-for="highlight in record.highlights" :key="highlight">{{ highlight }}</li> <li v-for="highlight in record.highlights" :key="highlight">{{ highlight }}</li>
</ul> </ul>
@@ -20,7 +20,7 @@ defineProps<{
</li> </li>
</ol> </ol>
<article> <article class="systems-operations">
<h3>{{ systemsOperations.title }}</h3> <h3>{{ systemsOperations.title }}</h3>
<p>{{ systemsOperations.summary }}</p> <p>{{ systemsOperations.summary }}</p>
<ul> <ul>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { PortfolioContent } from '@/content/types';
defineProps<{
identity: Readonly<PortfolioContent['identity']>;
}>();
const emit = defineEmits<{
explore: [event: MouseEvent];
}>();
</script>
<template>
<section id="start" class="hero-journey" data-layout="hero" aria-labelledby="start-heading">
<div class="hero-journey__content">
<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>
<a class="hero-journey__action" href="#selected-builds" @click="emit('explore', $event)">Explore work</a>
</div>
<p class="print-portfolio-label" aria-hidden="true">Portfolio</p>
</section>
</template>

View File

@@ -1,15 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SectionId, SectionLayout } from '@/content/types';
defineProps<{ defineProps<{
id: string; id: SectionId;
index: number; layout: Exclude<SectionLayout, 'hero'>;
eyebrow: string; eyebrow?: string;
heading: string; heading: string;
}>(); }>();
</script> </script>
<template> <template>
<section :id="id" class="portfolio-section" :aria-labelledby="`${id}-heading`" :data-section-index="index"> <section :id="id" class="portfolio-section" :aria-labelledby="`${id}-heading`" :data-layout="layout">
<p class="route-label">{{ String(index).padStart(2, '0') }} / {{ eyebrow }}</p> <p v-if="eyebrow" class="section-eyebrow">{{ eyebrow }}</p>
<h2 :id="`${id}-heading`" tabindex="-1">{{ heading }}</h2> <h2 :id="`${id}-heading`" tabindex="-1">{{ heading }}</h2>
<slot /> <slot />
</section> </section>

View File

@@ -8,7 +8,7 @@ withDefaults(defineProps<{ project: Readonly<PublicProject>; allowAutomaticVideo
</script> </script>
<template> <template>
<article> <article class="project-card" :data-project-id="project.id" :data-maturity="project.maturity">
<h3>{{ project.title }}</h3> <h3>{{ project.title }}</h3>
<p>{{ project.summary }}</p> <p>{{ project.summary }}</p>
<ProjectMedia <ProjectMedia

View File

@@ -2,34 +2,49 @@
import type { PublicProject } from '@/content/types'; import type { PublicProject } from '@/content/types';
import ProjectMedia from './ProjectMedia.vue'; import ProjectMedia from './ProjectMedia.vue';
withDefaults(defineProps<{ project: Readonly<PublicProject>; allowAutomaticVideo?: boolean }>(), { withDefaults(defineProps<{
project: Readonly<PublicProject>;
priority?: 'primary' | 'supporting';
allowAutomaticVideo?: boolean;
}>(), {
priority: 'supporting',
allowAutomaticVideo: false, allowAutomaticVideo: false,
}); });
</script> </script>
<template> <template>
<article> <article class="project-case-study" :data-project-id="project.id" :data-project-priority="priority">
<h3>{{ project.title }}</h3> <header class="project-case-study__header">
<p>{{ project.summary }}</p> <h3>{{ project.title }}</h3>
<p>{{ project.summary }}</p>
</header>
<ProjectMedia <ProjectMedia
v-for="media in project.media ?? []" v-for="media in project.media ?? []"
:key="`${media.kind}-${media.assetId}`" :key="`${media.kind}-${media.assetId}`"
:presentation="media" :presentation="media"
:allow-automatic-video="allowAutomaticVideo" :allow-automatic-video="allowAutomaticVideo"
/> />
<dl> <dl class="project-evidence">
<dt>Problem</dt> <div>
<dd>{{ project.problem }}</dd> <dt>Problem</dt>
<dt>Contribution</dt> <dd>{{ project.problem }}</dd>
<dd>{{ project.contribution }}</dd> </div>
<dt>Technical decisions</dt> <div>
<dd> <dt>Contribution</dt>
<ul> <dd>{{ project.contribution }}</dd>
<li v-for="decision in project.technicalDecisions" :key="decision">{{ decision }}</li> </div>
</ul> <div>
</dd> <dt>Technical decisions</dt>
<dt>Outcome</dt> <dd>
<dd>{{ project.outcome }}</dd> <ul>
<li v-for="decision in project.technicalDecisions" :key="decision">{{ decision }}</li>
</ul>
</dd>
</div>
<div>
<dt>Outcome</dt>
<dd>{{ project.outcome }}</dd>
</div>
</dl> </dl>
<a v-if="project.visibility === 'public' && project.url" class="project-link" :href="project.url" target="_blank" rel="noopener noreferrer">{{ project.title }}</a> <a v-if="project.visibility === 'public' && project.url" class="project-link" :href="project.url" target="_blank" rel="noopener noreferrer">{{ project.title }}</a>
</article> </article>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SectionId } from "@/content/types"; import { onMounted, ref } from 'vue';
import type { SectionId } from '@/content/types';
import type { JourneySnapshot } from '@/journey/types'; import type { JourneySnapshot } from '@/journey/types';
const props = defineProps<{ const props = defineProps<{
@@ -18,21 +19,35 @@ const navigationLabels: Readonly<Record<SectionId, string>> = {
toolkit: 'Toolkit', toolkit: 'Toolkit',
contact: 'Contact', contact: 'Contact',
}; };
const disclosure = ref<HTMLDetailsElement | null>(null);
onMounted(() => {
if (window.matchMedia('(max-width: 767px)').matches) disclosure.value?.removeAttribute('open');
});
const navigate = (event: MouseEvent): void => {
props.onSectionLinkClick(event);
if (window.matchMedia('(max-width: 767px)').matches) disclosure.value?.removeAttribute('open');
};
</script> </script>
<template> <template>
<nav class="site-navigation" aria-label="Portfolio sections"> <nav class="site-navigation" aria-label="Portfolio sections">
<ul> <details ref="disclosure" class="site-navigation__disclosure" open>
<li v-for="item in items" :key="item.id"> <summary>Navigate</summary>
<a <ul>
:href="`#${item.id}`" <li v-for="item in items" :key="item.id">
:aria-current=" <a
props.journey.activeSectionId === item.id ? 'page' : undefined :href="`#${item.id}`"
" :aria-current="
@click="props.onSectionLinkClick" props.journey.activeSectionId === item.id ? 'page' : undefined
>{{ navigationLabels[item.id] }}</a "
> @click="navigate"
</li> >{{ navigationLabels[item.id] }}</a
</ul> >
</li>
</ul>
</details>
</nav> </nav>
</template> </template>

View File

@@ -8,6 +8,16 @@ export type SectionId =
| 'toolkit' | 'toolkit'
| 'contact'; | 'contact';
export type SectionLayout =
| 'hero'
| 'right-rail'
| 'left-timeline'
| 'full-diagram'
| 'project-stage'
| 'compact-rail'
| 'toolkit-field'
| 'destination';
export type ProjectVisibility = 'public' | 'private'; export type ProjectVisibility = 'public' | 'private';
export type ProjectMaturity = 'learning' | 'experimental' | 'prototype' | 'work-in-progress' | 'active'; export type ProjectMaturity = 'learning' | 'experimental' | 'prototype' | 'work-in-progress' | 'active';

View File

@@ -45,6 +45,7 @@ button:focus-visible,
input:focus-visible, input:focus-visible,
textarea:focus-visible, textarea:focus-visible,
select:focus-visible, select:focus-visible,
summary:focus-visible,
[tabindex]:focus-visible { [tabindex]:focus-visible {
outline: 3px solid var(--focus); outline: 3px solid var(--focus);
outline-offset: 3px; outline-offset: 3px;

View File

@@ -1,20 +1,71 @@
.route-label { .section-eyebrow {
color: var(--cyan); color: var(--cyan);
font-family: var(--mono); font: 620 0.72rem/1.2 var(--mono);
font-size: var(--step--1); letter-spacing: 0.12em;
font-weight: 650; text-transform: uppercase;
letter-spacing: 0.08em; }
line-height: 1.3;
.hero-journey h1 {
font-size: var(--step-4);
letter-spacing: -0.055em;
}
.hero-journey__role {
color: var(--text);
font-size: var(--step-1);
font-weight: 520;
line-height: 1.28;
}
.hero-journey__value {
max-inline-size: 40ch;
color: var(--text-muted);
font-size: var(--step-0);
}
.hero-journey__action,
.project-link,
.contact-link,
.contact-social-link {
display: inline-flex;
min-block-size: 44px;
align-items: center;
inline-size: fit-content;
}
.hero-journey__action {
gap: 0.75rem;
margin-block-start: 0.35rem;
padding-inline: 0.1rem;
color: var(--cyan);
font: 600 0.82rem/1 var(--mono);
letter-spacing: 0.06em;
text-decoration: none;
}
.hero-journey__action::after {
content: '→';
transition: transform var(--transition);
}
.hero-journey__action:hover::after,
.hero-journey__action:focus-visible::after {
transform: translateX(0.25rem);
} }
.portfolio-section > h2 { .portfolio-section > h2 {
margin-block: var(--space-1) var(--space-2); margin-block: 0 var(--space-3);
font-size: var(--step-2); font-size: var(--step-2);
} }
.portfolio-section > p:not(.route-label), .portfolio-section > .section-eyebrow + h2 {
.portfolio-section article > p, margin-block-start: var(--space-1);
.portfolio-section dd { }
.portfolio-section > p,
.portfolio-section article p,
.portfolio-section dd,
.portfolio-section li {
color: var(--text-muted); color: var(--text-muted);
} }
@@ -24,15 +75,7 @@
margin-block-start: var(--space-2); margin-block-start: var(--space-2);
} }
.portfolio-section article { .portfolio-section h3 {
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); font-size: var(--step-1);
} }
@@ -41,23 +84,81 @@
padding-inline-start: 1.2rem; padding-inline-start: 1.2rem;
} }
.portfolio-section dl { .profile-facts {
display: grid; display: grid;
gap: var(--space-1) var(--space-2); grid-template-columns: minmax(7rem, 0.34fr) minmax(0, 1fr);
gap: 0;
margin-block-start: var(--space-3); margin-block-start: var(--space-3);
border-block-start: 1px solid var(--line);
} }
.portfolio-section dt { .profile-facts dt,
color: var(--cyan); .profile-facts dd {
font-family: var(--mono);
font-size: var(--step--1);
font-weight: 650;
letter-spacing: 0.04em;
}
.portfolio-section dd {
min-width: 0; min-width: 0;
margin-inline-start: 0; margin: 0;
padding-block: 0.8rem;
border-block-end: 1px solid var(--line);
}
.profile-facts dt,
.project-evidence dt {
color: var(--cyan);
font: 610 0.7rem/1.4 var(--mono);
letter-spacing: 0.07em;
text-transform: uppercase;
}
.experience-timeline {
position: relative;
margin: 0;
padding: 0 0 0 1.6rem !important;
list-style: none;
}
.experience-timeline::before {
position: absolute;
inset: 0 auto 0 0.23rem;
inline-size: 1px;
background: var(--line-strong);
content: '';
}
.experience-timeline > li {
position: relative;
margin-block-start: 0 !important;
padding-block: 0 2.5rem;
}
.experience-timeline > li::before {
position: absolute;
inset: 0.43rem auto auto -1.62rem;
inline-size: 0.5rem;
block-size: 0.5rem;
border: 1px solid var(--cyan);
background: var(--canvas);
content: '';
transform: rotate(45deg);
}
.experience-record {
padding-block-end: var(--space-2);
}
.experience-record__meta {
color: var(--cyan) !important;
font: 520 0.74rem/1.5 var(--mono);
}
.experience-record ul,
.systems-operations ul {
margin-block-start: var(--space-2);
}
.systems-operations {
margin-block-start: var(--space-2);
padding: var(--space-3);
border-inline-start: 2px solid var(--amber);
background: var(--surface-soft);
} }
.portfolio-section figure { .portfolio-section figure {
@@ -65,7 +166,8 @@
margin-block-start: var(--space-3); margin-block-start: var(--space-3);
padding: var(--space-2); padding: var(--space-2);
border: 1px solid var(--line); border: 1px solid var(--line);
background: var(--surface-raised); border-radius: var(--radius-control);
background: var(--surface-soft);
} }
.portfolio-section figcaption { .portfolio-section figcaption {
@@ -74,6 +176,224 @@
font-size: var(--step--1); 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;
}
[data-testid='infrastructure-text-equivalent'] {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
padding: 0 !important;
list-style: none;
}
[data-testid='infrastructure-text-equivalent'] li {
margin: 0;
padding: var(--space-2);
border-block-start: 1px solid var(--line);
}
[data-testid='infrastructure-text-equivalent'] strong {
color: var(--text);
}
.project-stage__projects {
display: grid;
gap: clamp(4rem, 12vh, 8rem);
margin-block-start: var(--space-4);
}
.project-case-study {
inline-size: 46%;
min-width: 0;
padding: clamp(1.4rem, 2.4vw, 2.4rem);
border: var(--panel-border);
border-radius: var(--radius-surface);
background: var(--surface);
box-shadow: var(--panel-shadow);
}
.project-case-study[data-project-id='footy-tracker'],
.project-case-study[data-project-id='public-build-two'] {
margin-inline-start: 54%;
}
.project-case-study[data-project-priority='supporting'] {
inline-size: min(38rem, 62%);
margin-inline: auto;
background: var(--surface-soft);
}
.project-case-study__header {
display: grid;
gap: var(--space-1);
padding-block-end: var(--space-3);
border-block-end: 1px solid var(--line);
}
.project-case-study__header h3 {
font-size: var(--step-2);
}
.project-case-study[data-project-priority='supporting'] .project-case-study__header h3 {
font-size: var(--step-1);
}
.project-evidence {
display: grid;
gap: 0;
margin-block-start: var(--space-2);
}
.project-evidence > div {
display: grid;
grid-template-columns: minmax(7.5rem, 0.3fr) minmax(0, 1fr);
gap: var(--space-2);
padding-block: var(--space-2);
border-block-end: 1px solid var(--line);
}
.project-evidence dd {
min-width: 0;
margin: 0;
}
.project-evidence ul {
margin: 0;
}
.project-link {
margin-block-start: var(--space-2);
padding-inline: 0.65rem;
border: 1px solid var(--line-strong);
font: 560 0.74rem/1 var(--mono);
text-decoration: none;
}
.compact-projects {
display: grid;
gap: var(--space-2);
}
.project-card {
min-width: 0;
padding: var(--space-2);
border-block-start: 1px solid var(--line);
}
.project-card[data-maturity='prototype'],
.project-card[data-maturity='active'] {
margin-inline-end: 8%;
}
.project-card[data-maturity='learning'] {
margin-inline-start: 12%;
}
.project-card h3 {
font-size: var(--step-0);
}
.portfolio-section > div:not(.project-stage__projects, .compact-projects, .contact-destination) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 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;
margin: 0;
padding: 0.25rem 0.55rem;
border: 1px solid var(--line);
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%;
}
.contact-destination {
display: grid;
gap: var(--space-2);
}
.contact-destination__copy {
max-inline-size: 34ch;
color: var(--text) !important;
font-size: var(--step-2);
font-weight: 560;
letter-spacing: -0.025em;
line-height: 1.14;
}
.contact-link {
padding-inline: 0.9rem;
border: 1px solid var(--cyan);
background: var(--cyan);
color: #041017;
font: 640 0.78rem/1 var(--mono);
text-decoration: none;
}
.contact-destination__email,
.contact-destination__cv {
font-size: var(--step--1);
}
.contact-social {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-block-start: var(--space-3);
padding: 0 !important;
list-style: none;
}
.contact-social li {
margin: 0;
}
.contact-social-link {
padding-inline: 0.2rem;
font: 540 0.74rem/1 var(--mono);
text-decoration: none;
}
.project-media { .project-media {
overflow: hidden; overflow: hidden;
} }
@@ -141,74 +461,64 @@
color: var(--text-muted); color: var(--text-muted);
} }
.project-link, @media (min-width: 768px) and (max-width: 1279px) {
.contact-link, .project-case-study {
.contact-social-link { inline-size: 62%;
display: inline-flex; }
align-items: center;
min-inline-size: 44px; .project-case-study[data-project-id='footy-tracker'],
min-block-size: 44px; .project-case-study[data-project-id='public-build-two'] {
padding-inline: 0.55rem; margin-inline-start: 38%;
}
.portfolio-section > div:not(.project-stage__projects, .compact-projects, .contact-destination) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 420px) { @media (max-width: 767px) {
.profile-facts,
.project-evidence > div,
[data-testid='infrastructure-text-equivalent'],
.portfolio-section > div:not(.project-stage__projects, .compact-projects, .contact-destination) {
grid-template-columns: 1fr;
}
.profile-facts dt {
padding-block-end: 0.15rem;
border-block-end: 0;
}
.profile-facts dd {
padding-block-start: 0.15rem;
}
.project-case-study,
.project-case-study[data-project-id],
.project-case-study[data-project-priority] {
inline-size: 100%;
margin-inline: 0;
padding: 1.35rem;
background: var(--surface-strong);
}
.project-stage__projects {
gap: 3rem;
}
.project-evidence > div {
gap: 0.4rem;
}
.project-card[data-maturity] {
margin-inline: 0;
}
.contact-destination__copy {
font-size: var(--step-1);
}
.chart-description { .chart-description {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.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%;
}

View File

@@ -1,5 +1,5 @@
:root { :root {
--site-navigation-block-size: 45px; --site-navigation-block-size: 53px;
} }
.portfolio-shell { .portfolio-shell {
@@ -7,40 +7,31 @@
} }
.site-header { .site-header {
display: grid; min-block-size: 0;
gap: var(--space-2);
min-width: 0;
padding: calc(var(--space-4) + var(--site-navigation-block-size)) 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 { .site-navigation {
position: fixed; position: fixed;
z-index: 3; z-index: var(--z-navigation);
inset: 0 0 auto; inset: 0 0 auto;
block-size: var(--site-navigation-block-size); min-block-size: var(--site-navigation-block-size);
border-block-end: var(--panel-border); border-block-end: 1px solid var(--line);
background: rgb(7 13 23 / 94%); background: var(--surface-strong);
backdrop-filter: blur(10px); }
.site-navigation__disclosure {
max-inline-size: 96rem;
margin-inline: auto;
}
.site-navigation summary {
display: none;
} }
.site-navigation ul { .site-navigation ul {
display: grid; display: grid !important;
grid-template-columns: repeat(8, minmax(0, 1fr)); grid-template-columns: repeat(8, minmax(0, 1fr));
grid-auto-rows: 44px; grid-auto-rows: 52px;
gap: 0.25rem;
max-inline-size: 90rem;
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
@@ -49,135 +40,265 @@
.site-navigation a { .site-navigation a {
display: inline-flex; display: inline-flex;
inline-size: 100%; inline-size: 100%;
align-items: center;
min-block-size: 44px; min-block-size: 44px;
align-items: center;
justify-content: center; justify-content: center;
padding-inline: 0.4rem; padding-inline: 0.45rem;
border-block-end: 2px solid transparent;
color: var(--text-muted); color: var(--text-muted);
font-family: var(--mono); font: 520 0.78rem/1 var(--mono);
font-size: var(--step--1); letter-spacing: 0.06em;
letter-spacing: 0.04em;
text-decoration: none; text-decoration: none;
transition: transition: color var(--transition), background-color var(--transition), border-color var(--transition);
color var(--transition),
background-color var(--transition);
} }
.site-navigation a:hover, .site-navigation a:hover,
.site-navigation a:focus-visible { .site-navigation a:focus-visible {
background: var(--cyan-deep); background: rgb(120 216 229 / 8%);
color: var(--text); color: var(--text);
} }
.site-navigation a[aria-current='page'] {
border-block-end-color: var(--cyan);
color: var(--cyan);
}
.site-navigation a:focus-visible { .site-navigation a:focus-visible {
outline-offset: -3px; outline-offset: -3px;
} }
.portfolio-main { .portfolio-main {
min-width: 0; min-width: 0;
padding-block: var(--space-4) var(--space-5); padding-block: var(--site-navigation-block-size) 18vh;
}
.hero-journey {
display: grid;
min-block-size: calc(100svh - var(--site-navigation-block-size));
align-items: start;
padding: clamp(3.5rem, 12vh, 8rem) 4vw 16vh;
scroll-margin-block-start: var(--site-navigation-block-size);
}
.hero-journey__content {
display: grid;
inline-size: min(39rem, 46vw);
gap: 1.1rem;
padding: clamp(1.5rem, 2.5vw, 2.5rem);
border: 1px solid var(--line);
border-inline-start: 3px solid var(--cyan);
border-radius: var(--radius-surface);
background: var(--surface);
box-shadow: var(--panel-shadow);
} }
.portfolio-section { .portfolio-section {
width: calc(100% - 2rem); position: relative;
min-width: 0; min-width: 0;
margin: 0 1rem var(--space-4); margin-block: clamp(6rem, 16vh, 11rem);
padding: var(--space-3); padding: clamp(1.5rem, 2.6vw, 2.7rem);
scroll-margin-block-start: calc( scroll-margin-block-start: calc(var(--site-navigation-block-size) + var(--space-3));
var(--site-navigation-block-size) + var(--space-3)
);
border: var(--panel-border); border: var(--panel-border);
border-radius: 2px; border-radius: var(--radius-surface);
background: var(--surface); background: var(--surface);
box-shadow: var(--panel-shadow); box-shadow: var(--panel-shadow);
transition:
border-color var(--transition),
background-color var(--transition);
} }
.portfolio-section:hover { .portfolio-section[data-layout='right-rail'] {
border-color: var(--line-strong); inline-size: 42%;
margin-inline: 54% 4%;
}
.portfolio-section[data-layout='left-timeline'] {
inline-size: 46%;
margin-inline: 4% 50%;
}
.portfolio-section[data-layout='full-diagram'] {
inline-size: 78%;
margin-inline: 11%;
}
.portfolio-section[data-layout='project-stage'] {
inline-size: 92%;
margin-inline: 4%;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.portfolio-section[data-layout='project-stage'] > h2,
.portfolio-section[data-layout='project-stage'] > .section-eyebrow {
inline-size: fit-content;
padding: 0.55rem 0.8rem;
border-inline-start: 2px solid var(--cyan);
background: var(--surface-strong);
}
.portfolio-section[data-layout='compact-rail'] {
inline-size: 42%;
margin-inline: 54% 4%;
}
.portfolio-section[data-layout='toolkit-field'] {
inline-size: 78%;
margin-inline: 11%;
}
.portfolio-section[data-layout='destination'] {
inline-size: min(54rem, 62%);
margin-inline: auto;
} }
.site-footer,
.portfolio-section footer { .portfolio-section footer {
display: grid;
gap: var(--space-2);
min-width: 0; min-width: 0;
} }
.site-footer { @media (min-width: 768px) and (max-width: 1279px) {
padding: var(--space-4) 4vw; .hero-journey {
border-block-start: var(--panel-border); padding-inline: 4vw;
background: #0b111b; }
.hero-journey__content {
inline-size: min(34rem, 62vw);
}
.portfolio-section[data-layout='right-rail'] {
inline-size: 60%;
margin-inline: 36% 4%;
}
.portfolio-section[data-layout='left-timeline'] {
inline-size: 60%;
margin-inline: 4% 36%;
}
.portfolio-section[data-layout='full-diagram'],
.portfolio-section[data-layout='toolkit-field'] {
inline-size: 84%;
margin-inline: 8%;
}
.portfolio-section[data-layout='project-stage'] {
inline-size: 92%;
margin-inline: 4%;
}
.portfolio-section[data-layout='compact-rail'] {
inline-size: 62%;
margin-inline: 34% 4%;
}
.portfolio-section[data-layout='destination'] {
inline-size: 70%;
}
} }
@media (max-width: 767px) { @media (max-width: 767px) {
:root { :root {
--site-navigation-block-size: 93px; --site-navigation-block-size: 53px;
} }
.site-header { .site-navigation__disclosure {
padding-block-start: calc( position: relative;
var(--space-4) + var(--site-navigation-block-size) }
);
.site-navigation summary {
display: flex;
min-block-size: 52px;
align-items: center;
justify-content: space-between;
padding-inline: 1rem;
color: var(--text);
cursor: pointer;
font: 560 0.76rem/1 var(--mono);
letter-spacing: 0.08em;
list-style: none;
}
.site-navigation summary::after {
color: var(--cyan);
content: '+';
font-size: 1.1rem;
}
.site-navigation details[open] summary::after {
content: '−';
}
.site-navigation summary::-webkit-details-marker {
display: none;
} }
.site-navigation ul { .site-navigation ul {
grid-template-columns: repeat(4, minmax(0, 1fr)); position: absolute;
inset: 100% 0 auto;
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 48px;
border-block: 1px solid var(--line);
background: var(--surface-strong);
box-shadow: var(--panel-shadow);
} }
.portfolio-section { .site-navigation details:not([open]) ul {
scroll-margin-block-start: calc( display: none !important;
var(--site-navigation-block-size) + var(--space-3) }
);
.portfolio-main {
padding-block-end: 8rem;
}
.hero-journey {
min-block-size: calc(100svh - var(--site-navigation-block-size));
align-items: start;
padding: clamp(2rem, 9vh, 4.5rem) 1rem 8rem;
}
.hero-journey__content {
inline-size: 100%;
gap: 0.9rem;
padding: 1.4rem;
background: var(--surface-strong);
}
.portfolio-section,
.portfolio-section[data-layout] {
inline-size: calc(100% - 2rem);
margin: 6rem 1rem;
padding: 1.4rem;
background: var(--surface-strong);
}
.portfolio-section[data-layout='project-stage'] {
padding: 0;
border: 0;
background: transparent;
}
.portfolio-section[data-layout='project-stage'] > h2,
.portfolio-section[data-layout='project-stage'] > .section-eyebrow {
margin-inline-start: 0;
background: var(--surface-strong);
} }
.project-link, .project-link,
.contact-link, .contact-link,
.contact-social-link { .contact-social-link {
display: inline-flex;
align-items: center;
min-inline-size: 44px; min-inline-size: 44px;
min-block-size: 44px; min-block-size: 44px;
padding-inline: 0.55rem;
} }
} }
@media (min-width: 768px) { @media (max-height: 420px) {
.site-header { .hero-journey {
padding-inline: 5vw; min-block-size: auto;
padding-block: 4rem 3rem;
} }
.portfolio-section { .portfolio-section,
width: calc(50% - 3rem); .portfolio-section[data-layout] {
margin-inline: 2rem 0; margin-block: 4rem;
}
.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;
} }
} }

View File

@@ -24,6 +24,7 @@
.portfolio-shell, .portfolio-shell,
.site-header, .site-header,
.portfolio-main, .portfolio-main,
.hero-journey,
.site-footer { .site-footer {
display: block; display: block;
padding: 0; padding: 0;
@@ -31,8 +32,22 @@
background: transparent; background: transparent;
} }
.site-header { .hero-journey {
margin-block-end: 1.5rem; margin-block-end: 1.5rem;
min-block-size: 0;
}
.hero-journey__content {
display: block;
inline-size: 100%;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.hero-journey__content > * + * {
margin-block-start: 0.4rem;
} }
.print-portfolio-label { .print-portfolio-label {
@@ -46,7 +61,8 @@
text-transform: uppercase; text-transform: uppercase;
} }
.portfolio-section { .portfolio-section,
.portfolio-section[data-layout] {
width: 100%; width: 100%;
margin: 0 0 1.25rem; margin: 0 0 1.25rem;
padding: 0 0 1.25rem; padding: 0 0 1.25rem;
@@ -58,6 +74,19 @@
box-shadow: none; box-shadow: none;
} }
.portfolio-section[data-layout='project-stage'] > h2,
.portfolio-section[data-layout='project-stage'] > .section-eyebrow,
.project-case-study,
.project-case-study[data-project-id],
.project-case-study[data-project-priority] {
inline-size: 100%;
margin-inline: 0;
padding-inline: 0;
border-inline: 0;
background: transparent;
box-shadow: none;
}
a { a {
color: inherit; color: inherit;
text-decoration: underline; text-decoration: underline;

View File

@@ -120,17 +120,12 @@
.site-header { .site-header {
position: relative; position: relative;
z-index: 2; z-index: 2;
background: rgb(7 13 23 / 96%);
} }
.portfolio-main { .portfolio-main {
isolation: isolate; isolation: isolate;
} }
.portfolio-section {
background: rgb(16 23 34 / 94%);
}
.journey-hud { .journey-hud {
position: fixed; position: fixed;
z-index: var(--z-hud); z-index: var(--z-hud);

View File

@@ -39,9 +39,9 @@ const captureDocument = async (
...document.querySelectorAll<HTMLElement>('main > section > h2'), ...document.querySelectorAll<HTMLElement>('main > section > h2'),
].map((heading) => heading.innerText.trim()), ].map((heading) => heading.innerText.trim()),
h1: document.querySelector('h1')?.textContent?.trim() ?? '', h1: document.querySelector('h1')?.textContent?.trim() ?? '',
role: document.querySelector('.site-header > p')?.textContent?.trim() ?? '', role: document.querySelector('.hero-journey__role')?.textContent?.trim() ?? '',
landmarks: { landmarks: {
headers: document.querySelectorAll('header').length, headers: document.querySelectorAll('.site-header').length,
mains: document.querySelectorAll('main').length, mains: document.querySelectorAll('main').length,
navigations: document.querySelectorAll('nav').length, navigations: document.querySelectorAll('nav').length,
footers: document.querySelectorAll('footer').length, footers: document.querySelectorAll('footer').length,

View File

@@ -15,14 +15,13 @@ const sectionIds = [
] as const; ] as const;
const sectionHeadings = [ const sectionHeadings = [
'START', 'Profile',
'PROFILE', 'Experience',
'EXPERIENCE', 'Infrastructure',
'INFRASTRUCTURE', 'Selected builds',
'SELECTED BUILDS', 'Additional work',
'ADDITIONAL WORK', 'Toolkit',
'TOOLKIT', 'Contact',
'CONTACT',
] as const; ] as const;
const navigationLabels: Readonly<Record<(typeof sectionIds)[number], string>> = { const navigationLabels: Readonly<Record<(typeof sectionIds)[number], string>> = {
@@ -90,14 +89,15 @@ const expectedVisibleTextSegments = (
content.navigation.map((item) => [item.id, item.label]), content.navigation.map((item) => [item.id, item.label]),
); );
const section = ( const section = (
id: (typeof sectionIds)[number], id: Exclude<(typeof sectionIds)[number], 'start'>,
index: number,
body: readonly string[], body: readonly string[],
eyebrow?: string,
): string[] => { ): string[] => {
const label = navigationById.get(id); const label = navigationById.get(id);
if (label === undefined) throw new Error(`Missing navigation label: ${id}`); if (label === undefined) throw new Error(`Missing navigation label: ${id}`);
const heading = label.toUpperCase(); return [eyebrow, label, ...body].filter(
return [`${String(index).padStart(2, '0')} / ${heading}`, heading, ...body]; (segment): segment is string => segment !== undefined,
);
}; };
const projectLinkText = ( const projectLinkText = (
project: PortfolioContent['projects']['selected'][number], project: PortfolioContent['projects']['selected'][number],
@@ -105,18 +105,19 @@ const expectedVisibleTextSegments = (
project.visibility === 'public' && project.url ? [project.title] : []; project.visibility === 'public' && project.url ? [project.title] : [];
return [ return [
...sectionIds.map((id) => navigationLabels[id]),
content.identity.name, content.identity.name,
content.identity.role, content.identity.role,
...sectionIds.map((id) => navigationLabels[id]), content.identity.valueProposition,
...section('start', 1, [content.identity.valueProposition]), 'Explore work',
...section('profile', 2, [ ...section('profile', [
content.profile.introduction, content.profile.introduction,
...content.profile.screeningFacts.flatMap((fact) => [ ...content.profile.screeningFacts.flatMap((fact) => [
fact.label, fact.label,
fact.value, fact.value,
]), ]),
]), ]),
...section('experience', 3, [ ...section('experience', [
...content.experience.flatMap((record) => [ ...content.experience.flatMap((record) => [
record.role, record.role,
`${record.organization} · ${record.dates}${record.location === undefined ? '' : ` · ${record.location}`}`, `${record.organization} · ${record.dates}${record.location === undefined ? '' : ` · ${record.location}`}`,
@@ -126,7 +127,7 @@ const expectedVisibleTextSegments = (
content.systemsOperations.summary, content.systemsOperations.summary,
...content.systemsOperations.evidence, ...content.systemsOperations.evidence,
]), ]),
...section('infrastructure', 4, [ ...section('infrastructure', [
content.infrastructure.title, content.infrastructure.title,
content.infrastructure.summary, content.infrastructure.summary,
...content.infrastructure.bands.map((band) => band.title), ...content.infrastructure.bands.map((band) => band.title),
@@ -137,7 +138,6 @@ const expectedVisibleTextSegments = (
]), ]),
...section( ...section(
'selected-builds', 'selected-builds',
5,
content.projects.selected.flatMap((project) => [ content.projects.selected.flatMap((project) => [
project.title, project.title,
project.summary, project.summary,
@@ -151,10 +151,10 @@ const expectedVisibleTextSegments = (
project.outcome, project.outcome,
...projectLinkText(project), ...projectLinkText(project),
]), ]),
'Selected work',
), ),
...section( ...section(
'additional-work', 'additional-work',
6,
content.projects.additional.flatMap((project) => [ content.projects.additional.flatMap((project) => [
project.title, project.title,
project.summary, project.summary,
@@ -165,19 +165,18 @@ const expectedVisibleTextSegments = (
), ),
...section( ...section(
'toolkit', 'toolkit',
7,
content.toolkit.flatMap((group) => [ content.toolkit.flatMap((group) => [
group.title, group.title,
...group.technologies.map((technology) => technology.name), ...group.technologies.map((technology) => technology.name),
]), ]),
), ),
...section('contact', 8, [ ...section('contact', [
content.identity.name, "Thanks for making the trip. The road's open if you'd like to build something together.",
content.identity.role, 'Start a conversation',
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),
]), ], 'Destination'),
]; ];
}; };
@@ -347,7 +346,7 @@ test('keeps every raw SSR reading-path segment visible without JavaScript', asyn
await expect(page.locator('h1:visible')).toHaveCount(1); await expect(page.locator('h1:visible')).toHaveCount(1);
await expect(page.locator('h1')).toHaveText('Luca Pîslaru'); await expect(page.locator('h1')).toHaveText('Luca Pîslaru');
await expect(page.locator('.site-header > p').first()).toHaveText( await expect(page.locator('.hero-journey__role')).toHaveText(
'System Administrator & Full-Stack Developer', 'System Administrator & Full-Stack Developer',
); );
await expect(page.locator('main')).toBeVisible(); await expect(page.locator('main')).toBeVisible();
@@ -381,7 +380,7 @@ test('keeps every raw SSR reading-path segment visible without JavaScript', asyn
const email = page.locator('a[href="mailto:luca.pislaru@gmail.com"]'); const email = page.locator('a[href="mailto:luca.pislaru@gmail.com"]');
await expect(email).toBeVisible(); await expect(email).toBeVisible();
await expect(email).toHaveText('luca.pislaru@gmail.com'); await expect(email).toHaveText('Start a conversation');
await expect( await expect(
page.getByText( page.getByText(
'For a current CV or role-specific information, contact me directly.', 'For a current CV or role-specific information, contact me directly.',

View File

@@ -70,8 +70,8 @@ test('renders the validated eight-section portfolio structure before responsive
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await loadInformationLayer(page); await loadInformationLayer(page);
await expect(page.locator('.portfolio-main > .portfolio-section')).toHaveCount(8); await expect(page.locator('.portfolio-main > section')).toHaveCount(8);
expect(await page.locator('.portfolio-main > .portfolio-section').evaluateAll((sections) => sections.map((section) => section.id))) expect(await page.locator('.portfolio-main > section').evaluateAll((sections) => sections.map((section) => section.id)))
.toEqual(['start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact']); .toEqual(['start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact']);
expect(await page.locator('.site-navigation a').evaluateAll((links) => links.map((link) => link.getAttribute('href')))) expect(await page.locator('.site-navigation a').evaluateAll((links) => links.map((link) => link.getAttribute('href'))))
.toEqual(['#start', '#profile', '#experience', '#infrastructure', '#selected-builds', '#additional-work', '#toolkit', '#contact']); .toEqual(['#start', '#profile', '#experience', '#infrastructure', '#selected-builds', '#additional-work', '#toolkit', '#contact']);
@@ -108,19 +108,23 @@ for (const viewport of [
}); });
} }
test('uses alternating desktop panels while preserving a clear central road corridor', async ({ page }, testInfo) => { test('uses explicit desktop layout families while preserving the primary-project road corridor', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await loadInformationLayer(page); await loadInformationLayer(page);
const panels = await page.locator('.portfolio-main > .portfolio-section').evaluateAll((sections, viewportWidth) => expect(await page.locator('.portfolio-main > section').evaluateAll((sections) =>
sections.map((section) => { sections.map((section) => section.getAttribute('data-layout')),
const rect = section.getBoundingClientRect(); )).toEqual(['hero', 'right-rail', 'left-timeline', 'full-diagram', 'project-stage', 'compact-rail', 'toolkit-field', 'destination']);
return { left: rect.left, right: rect.right, widthRatio: rect.width / Number(viewportWidth) }; const projects = await page.locator('#selected-builds [data-project-priority="primary"]').evaluateAll((articles) =>
}), 1440, articles.map((article) => {
const rect = article.getBoundingClientRect();
return { left: rect.left, right: rect.right, width: rect.width };
}),
); );
expect(projects).toHaveLength(2);
expect(panels.every((panel) => panel.widthRatio >= 0.4 && panel.widthRatio <= 0.45)).toBe(true); expect(projects[0]!.right).toBeLessThanOrEqual(1440 * 0.51);
expect(panels.every((panel, index) => index % 2 === 0 ? panel.right <= 1440 * 0.461 : panel.left >= 1440 * 0.539)).toBe(true); expect(projects[1]!.left).toBeGreaterThanOrEqual(1440 * 0.53);
expect(projects.every((project) => project.width <= 1440 * 0.47)).toBe(true);
await capture(page, testInfo, 'information-layer-desktop-corridor.png'); await capture(page, testInfo, 'information-layer-desktop-corridor.png');
}); });
@@ -128,10 +132,12 @@ test('uses a stable tablet rhythm and a single readable mobile column with usabl
await page.setViewportSize({ width: 768, height: 1024 }); await page.setViewportSize({ width: 768, height: 1024 });
await loadInformationLayer(page); await loadInformationLayer(page);
const tabletPanels = await page.locator('.portfolio-main > .portfolio-section').evaluateAll((sections) => const tabletPanels = await page.locator('.portfolio-main > .portfolio-section').evaluateAll((sections) =>
sections.map((section) => section.getBoundingClientRect()), Object.fromEntries(sections.map((section) => [section.getAttribute('data-layout'), section.getBoundingClientRect().toJSON()])),
); );
expect(tabletPanels.every((panel) => panel.width < 768 * 0.7)).toBe(true); expect(tabletPanels['right-rail'].left).toBeGreaterThan(768 * 0.3);
expect(tabletPanels.every((panel, index) => index % 2 === 0 ? panel.left < 768 / 2 : panel.left >= 768 / 2)).toBe(true); expect(tabletPanels['left-timeline'].left).toBeLessThan(768 * 0.1);
expect(tabletPanels['full-diagram'].width).toBeGreaterThan(768 * 0.8);
expect(tabletPanels['compact-rail'].left).toBeGreaterThan(768 * 0.3);
await capture(page, testInfo, 'information-layer-tablet.png'); await capture(page, testInfo, 'information-layer-tablet.png');
await page.setViewportSize({ width: 320, height: 800 }); await page.setViewportSize({ width: 320, height: 800 });

View File

@@ -10,41 +10,49 @@ test('keeps the decorative world separate from the semantic reading path', async
await expect(page.locator('main#portfolio-content')).toBeVisible(); await expect(page.locator('main#portfolio-content')).toBeVisible();
}); });
test('keeps the desktop identity and navigation in a paint layer above the world', async ({ page, distUrl }) => { test('keeps the hero and navigation in paint layers above the world', async ({ page, distUrl }) => {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(distUrl, { waitUntil: 'domcontentloaded' }); await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
const layers = await page.evaluate(() => { const layers = await page.evaluate(() => {
const world = document.querySelector<HTMLElement>('.journey-world'); const world = document.querySelector<HTMLElement>('.journey-world');
const header = document.querySelector<HTMLElement>('.site-header'); const shell = document.querySelector<HTMLElement>('.portfolio-shell');
const heading = document.querySelector<HTMLElement>('.site-header h1'); const hero = document.querySelector<HTMLElement>('.hero-journey__content');
const heading = document.querySelector<HTMLElement>('.hero-journey h1');
const navigation = document.querySelector<HTMLElement>('.site-navigation'); const navigation = document.querySelector<HTMLElement>('.site-navigation');
if (!world || !header || !heading || !navigation) throw new Error('Missing top-screen structure'); if (!world || !shell || !hero || !heading || !navigation) throw new Error('Missing top-screen structure');
const worldStyle = getComputedStyle(world); const worldStyle = getComputedStyle(world);
const headerStyle = getComputedStyle(header); const heroStyle = getComputedStyle(hero);
const navigationStyle = getComputedStyle(navigation);
return { return {
worldZ: Number(worldStyle.zIndex), worldZ: Number(worldStyle.zIndex),
headerZ: Number(headerStyle.zIndex), shellZ: Number(getComputedStyle(shell).zIndex),
headerPosition: headerStyle.position, navigationZ: Number(navigationStyle.zIndex),
navigationPosition: navigationStyle.position,
headingVisible: heading.getBoundingClientRect().width > 0 && heading.getBoundingClientRect().height > 0, headingVisible: heading.getBoundingClientRect().width > 0 && heading.getBoundingClientRect().height > 0,
navigationVisible: navigation.getBoundingClientRect().width > 0 && navigation.getBoundingClientRect().height > 0, navigationVisible: navigation.getBoundingClientRect().width > 0 && navigation.getBoundingClientRect().height > 0,
headerOpacity: Number.parseFloat(headerStyle.backgroundColor.match(/[\d.]+(?=\))/gu)?.at(-1) ?? '1'), heroOpacity: Number.parseFloat(heroStyle.backgroundColor.match(/[\d.]+(?=\))/gu)?.at(-1) ?? '1'),
}; };
}); });
expect(layers).toMatchObject({ expect(layers).toMatchObject({
headerPosition: 'relative', navigationPosition: 'fixed',
headingVisible: true, headingVisible: true,
navigationVisible: true, navigationVisible: true,
}); });
expect(layers.headerZ).toBeGreaterThan(layers.worldZ); expect(layers.shellZ).toBeGreaterThan(layers.worldZ);
expect(layers.headerOpacity).toBeGreaterThanOrEqual(0.94); expect(layers.navigationZ).toBeGreaterThan(layers.worldZ);
expect(layers.heroOpacity).toBeGreaterThanOrEqual(0.94);
}); });
test('keeps a scrolled content panel in a geometry-safe paint layer above the world', async ({ page, distUrl }) => { test('keeps a scrolled content panel in a geometry-safe paint layer above the world', async ({ page, distUrl }) => {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(distUrl, { waitUntil: 'domcontentloaded' }); await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await page.locator('nav a[href="#profile"]').click(); const profileLink = page.locator('nav a[href="#profile"]');
if (!(await profileLink.isVisible())) await page.locator('.site-navigation summary').click();
await profileLink.click();
await expect(page).toHaveURL(`${distUrl}/#profile`);
await expect(page.locator('#profile-heading')).toBeInViewport();
const layers = await page.evaluate(() => { const layers = await page.evaluate(() => {
const world = document.querySelector<HTMLElement>('.journey-world'); const world = document.querySelector<HTMLElement>('.journey-world');
@@ -75,7 +83,6 @@ test('keeps a scrolled content panel in a geometry-safe paint layer above the wo
headingVisible: true, headingVisible: true,
}); });
expect(layers.panelOpacity).toBeGreaterThanOrEqual(0.94); expect(layers.panelOpacity).toBeGreaterThanOrEqual(0.94);
await expect(page).toHaveURL(`${distUrl}/#profile`);
}); });
test('provides native motion and quality controls with visible state', async ({ page, distUrl }) => { test('provides native motion and quality controls with visible state', async ({ page, distUrl }) => {
@@ -123,7 +130,9 @@ test('never creates a canvas when the system requests reduced motion', async ({
test('shows ARRIVED from the authoritative section snapshot at Contact', async ({ page, distUrl }) => { test('shows ARRIVED from the authoritative section snapshot at Contact', async ({ page, distUrl }) => {
await page.goto(distUrl, { waitUntil: 'domcontentloaded' }); await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await page.locator('nav a[href="#contact"]').click(); const contactLink = page.locator('nav a[href="#contact"]');
if (!(await contactLink.isVisible())) await page.locator('.site-navigation summary').click();
await contactLink.click();
await expect(page.locator('.journey-hud')).toContainText('Contact'); await expect(page.locator('.journey-hud')).toContainText('Contact');
await expect(page.locator('.journey-hud')).toContainText('ARRIVED'); await expect(page.locator('.journey-hud')).toContainText('ARRIVED');

View File

@@ -15,14 +15,13 @@ const sectionIds = [
] as const; ] as const;
const sectionHeadings = [ const sectionHeadings = [
'START', 'Profile',
'PROFILE', 'Experience',
'EXPERIENCE', 'Infrastructure',
'INFRASTRUCTURE', 'Selected builds',
'SELECTED BUILDS', 'Additional work',
'ADDITIONAL WORK', 'Toolkit',
'TOOLKIT', 'Contact',
'CONTACT',
] as const; ] as const;
const rawStatus = async ( const rawStatus = async (
@@ -81,8 +80,8 @@ test('exposes the complete semantic portfolio from the generated document', asyn
await page.goto(distUrl, { waitUntil: 'domcontentloaded' }); await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('h1:visible')).toHaveCount(1); await expect(page.locator('h1:visible')).toHaveCount(1);
await expect(page.locator('h1')).toHaveText('Luca Pîslaru'); await expect(page.locator('#start h1')).toHaveText('Luca Pîslaru');
await expect(page.locator('.site-header > p').first()).toHaveText( await expect(page.locator('.hero-journey__role')).toHaveText(
'System Administrator & Full-Stack Developer', 'System Administrator & Full-Stack Developer',
); );
await expect(page.locator('main')).toHaveCount(1); await expect(page.locator('main')).toHaveCount(1);
@@ -99,6 +98,9 @@ test('exposes the complete semantic portfolio from the generated document', asyn
expect(await page.locator('main > section > h2').allTextContents()).toEqual( expect(await page.locator('main > section > h2').allTextContents()).toEqual(
sectionHeadings, sectionHeadings,
); );
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');
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) =>
@@ -122,7 +124,7 @@ test('loads the self-hosted Geist families for information and route text', asyn
sansLoaded: document.fonts.check('16px "Geist Variable"'), sansLoaded: document.fonts.check('16px "Geist Variable"'),
monoLoaded: document.fonts.check('16px "Geist Mono Variable"'), monoLoaded: document.fonts.check('16px "Geist Mono Variable"'),
headingFamily: getComputedStyle(document.querySelector('h1')!).fontFamily, headingFamily: getComputedStyle(document.querySelector('h1')!).fontFamily,
routeFamily: getComputedStyle(document.querySelector('.route-label')!).fontFamily, routeFamily: getComputedStyle(document.querySelector('.section-eyebrow')!).fontFamily,
})); }));
expect(fonts).toMatchObject({ sansLoaded: true, monoLoaded: true }); expect(fonts).toMatchObject({ sansLoaded: true, monoLoaded: true });
@@ -149,7 +151,7 @@ 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(1);
await expect(email).toHaveText('luca.pislaru@gmail.com'); await expect(email).toHaveText('Start a conversation');
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);

View File

@@ -22,7 +22,7 @@
"id": "experience-operations", "id": "experience-operations",
"role": "Systems Administrator", "role": "Systems Administrator",
"organization": "Example Systems", "organization": "Example Systems",
"dates": "Jan 2024 – Present", "dates": "Jan 2024 - Present",
"location": "Remote", "location": "Remote",
"highlights": ["Operated Linux services.", "Maintained deployment workflows."] "highlights": ["Operated Linux services.", "Maintained deployment workflows."]
}], }],

View File

@@ -68,7 +68,7 @@ describe('raw prerendered document', () => {
expect(document.body.textContent).toContain('Private self-hosted medication tracker'); expect(document.body.textContent).toContain('Private self-hosted medication tracker');
expect(document.body.textContent).toContain('Linux'); expect(document.body.textContent).toContain('Linux');
expect(document.querySelector('a[href="mailto:luca.pislaru@gmail.com"]')?.textContent) expect(document.querySelector('a[href="mailto:luca.pislaru@gmail.com"]')?.textContent)
.toBe('luca.pislaru@gmail.com'); .toBe('Start a conversation');
expect(document.querySelector('nav a[href="#infrastructure"]')).not.toBeNull(); expect(document.querySelector('nav a[href="#infrastructure"]')).not.toBeNull();
expect(snapshot).not.toBeNull(); expect(snapshot).not.toBeNull();
expect(moduleScript).not.toBeNull(); expect(moduleScript).not.toBeNull();

View File

@@ -274,8 +274,8 @@ describe('artifact verification', () => {
{ {
name: 'identity rendered only inside a template', name: 'identity rendered only inside a template',
mutate: (html: string) => html.replace( mutate: (html: string) => html.replace(
'<h1>Luca Pîslaru</h1><p>System Administrator &amp; Full-Stack Developer</p>', /(<h1[^>]*>Luca Pîslaru<\/h1><p class="hero-journey__role">System Administrator &amp; Full-Stack Developer<\/p>)/u,
'<template><h1>Luca Pîslaru</h1><p>System Administrator &amp; Full-Stack Developer</p></template>', '<template>$1</template>',
), ),
}, },
{ {

View File

@@ -29,22 +29,56 @@ describe('semantic portfolio document', () => {
it('renders the complete landmark and heading reading structure on the server', async () => { it('renders the complete landmark and heading reading structure on the server', async () => {
const document = await renderedDocument(); const document = await renderedDocument();
expect(document.querySelectorAll('header')).toHaveLength(1); expect(document.querySelectorAll('.site-header')).toHaveLength(1);
expect(document.querySelector('nav[aria-label="Portfolio sections"]')).not.toBeNull(); expect(document.querySelector('nav[aria-label="Portfolio sections"]')).not.toBeNull();
expect(document.querySelectorAll('main')).toHaveLength(1); expect(document.querySelectorAll('main')).toHaveLength(1);
expect(document.querySelectorAll('footer')).toHaveLength(1); expect(document.querySelectorAll('footer')).toHaveLength(1);
expect(document.querySelectorAll('h1')).toHaveLength(1); expect(document.querySelectorAll('h1')).toHaveLength(1);
expect(document.querySelector('h1')?.textContent).toBe('Luca Pîslaru'); expect(document.querySelector('#start h1')?.textContent).toBe('Luca Pîslaru');
expect([...document.querySelectorAll('main > section')].map((section) => section.id)).toEqual([ expect([...document.querySelectorAll('main > section')].map((section) => section.id)).toEqual([
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact', 'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
]); ]);
expect([...document.querySelectorAll('main > section')].every((section) => section.hasAttribute('aria-labelledby'))).toBe(true); expect([...document.querySelectorAll('main > section')].every((section) => section.hasAttribute('aria-labelledby'))).toBe(true);
expect([...document.querySelectorAll('main > section > h2')].map((heading) => heading.textContent)).toEqual([ expect([...document.querySelectorAll('main > section > h2')].map((heading) => heading.textContent)).toEqual([
'START', 'PROFILE', 'EXPERIENCE', 'INFRASTRUCTURE', 'SELECTED BUILDS', 'ADDITIONAL WORK', 'TOOLKIT', 'CONTACT', 'Profile', 'Experience', 'Infrastructure', 'Selected builds', 'Additional work', 'Toolkit', 'Contact',
]); ]);
expect([...document.querySelectorAll('main > section')].map((section) => section.getAttribute('data-layout'))).toEqual([
'hero', 'right-rail', 'left-timeline', 'full-diagram', 'project-stage', 'compact-rail', 'toolkit-field', 'destination',
]);
expect(document.querySelectorAll('.route-label')).toHaveLength(0);
expect(document.querySelector('h2 + h4')).toBeNull(); expect(document.querySelector('h2 + h4')).toBeNull();
}); });
it('renders the exact first-screen hierarchy and destination actions', async () => {
const document = await renderedDocument();
const hero = document.querySelector('.hero-journey__content')!;
expect([...hero.children].map((element) => element.textContent?.trim())).toEqual([
'Luca Pîslaru',
'System Administrator & Full-Stack Developer',
'Building and operating reliable systems and software.',
'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(document.body.textContent).not.toMatch(/[–—]/u);
});
it('assigns explicit project priority without promoting research exploration', async () => {
const document = await renderedDocument();
const priorities = [...document.querySelectorAll('#selected-builds article')].map((article) => [
article.querySelector('h3')?.textContent,
article.getAttribute('data-project-priority'),
]);
expect(priorities).toEqual([
['Private self-hosted medication tracker', 'primary'],
['Footy Tracker', 'primary'],
['Opinion Dynamics Simulation', 'supporting'],
]);
});
it('keeps native section links and visible contact available without JavaScript', async () => { it('keeps native section links and visible contact available without JavaScript', async () => {
const document = await renderedDocument(); const document = await renderedDocument();
const navigation = document.querySelector('nav[aria-label="Portfolio sections"]')!; const navigation = document.querySelector('nav[aria-label="Portfolio sections"]')!;
@@ -53,7 +87,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('hello@example.test'); expect(email?.textContent).toBe('Start a conversation');
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.');
}); });
@@ -89,7 +123,7 @@ describe('semantic portfolio document', () => {
const document = await renderedDocumentFor(reorderedInputs); const document = await renderedDocumentFor(reorderedInputs);
expect([...document.querySelectorAll('main > section > h2')].map((heading) => heading.textContent)).toEqual([ expect([...document.querySelectorAll('main > section > h2')].map((heading) => heading.textContent)).toEqual([
'START', 'PROFILE', 'EXPERIENCE', 'INFRASTRUCTURE', 'SELECTED BUILDS', 'ADDITIONAL WORK', 'TOOLKIT', 'CONTACT', 'Profile', 'Experience', 'Infrastructure', 'Selected builds', 'Additional work', 'Toolkit', 'Contact',
]); ]);
expect([...document.querySelectorAll('nav a')].map((link) => [link.getAttribute('href'), link.textContent])).toEqual([ expect([...document.querySelectorAll('nav a')].map((link) => [link.getAttribute('href'), link.textContent])).toEqual([
['#start', 'Start'], ['#profile', 'Profile'], ['#experience', 'Career'], ['#infrastructure', 'Infra'], ['#start', 'Start'], ['#profile', 'Profile'], ['#experience', 'Career'], ['#infrastructure', 'Infra'],

View File

@@ -51,14 +51,15 @@ describe('semantic section components', () => {
}); });
it('does not turn private project content into a link container', () => { it('does not turn private project content into a link container', () => {
const wrapper = mount(ProjectCaseStudy, { props: { project: privateProject } }); const wrapper = mount(ProjectCaseStudy, { props: { project: privateProject, priority: 'primary' } });
expect(wrapper.get('article').findAll('a')).toHaveLength(0); expect(wrapper.get('article').findAll('a')).toHaveLength(0);
expect(wrapper.get('h3').text()).toBe('Private self-hosted medication tracker'); expect(wrapper.get('h3').text()).toBe('Private self-hosted medication tracker');
expect(wrapper.get('article').attributes('data-project-priority')).toBe('primary');
}); });
it('uses a safe external link only for a public project with an approved URL', () => { it('uses a safe external link only for a public project with an approved URL', () => {
const wrapper = mount(ProjectCaseStudy, { props: { project: publicProject } }); const wrapper = mount(ProjectCaseStudy, { props: { project: publicProject, priority: 'supporting' } });
const link = wrapper.get('article a'); const link = wrapper.get('article a');
expect(link.attributes('href')).toBe('https://github.com/example/footy-tracker'); expect(link.attributes('href')).toBe('https://github.com/example/footy-tracker');
@@ -88,11 +89,11 @@ describe('semantic section components', () => {
const wrapper = mount(ContactDestination, { const wrapper = mount(ContactDestination, {
props: { props: {
contact, contact,
identity: { name: 'Luca Pîslaru', role: 'System Administrator & Full-Stack Developer' },
}, },
}); });
expect(wrapper.get('a[href="mailto:hello@example.test"]').text()).toBe('hello@example.test'); 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.");
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('noopener noreferrer');