feat: render semantic portfolio document

This commit is contained in:
2026-09-02 01:17:45 +03:00
parent bc851e398b
commit 7d136ef16d
17 changed files with 488 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
# Task 4 — Semantic Vue document
## Summary
Implemented the universal Vue SSR document and typed semantic section components. The rendered document exposes the full eight-section portfolio reading order before hydration, with no visual-scene, audio, motion, SEO, or prerender work added.
## Files
- Added `src/app.ts`, `src/entry-client.ts`, `src/entry-server.ts`, and `src/App.vue`.
- Added semantic shell, navigation, section, experience, infrastructure, project, toolkit, and contact components under `src/components/`.
- Added SSR/component coverage in `tests/unit/AppShell.test.ts` and `tests/unit/components.test.ts`.
- Repaired `vitest.config.ts` to load the already-installed Vue plugin; this was authorized as the necessary Task 1 integration repair.
## RED / GREEN evidence
- **RED:** `npm run test:unit -- tests/unit/AppShell.test.ts tests/unit/components.test.ts` failed before implementation. Vitest could not resolve the missing `@/entry-server` and `@/components/ContactDestination.vue` modules, so neither requested SSR nor component behavior existed.
- **Integration RED:** after adding the components, the same standard command failed because `vitest.config.ts` did not load `@vitejs/plugin-vue`, reporting that `.vue` source could not be parsed.
- **GREEN:** after the implementation and minimal Vue-plugin config repair, `npm run test:unit -- tests/unit/AppShell.test.ts tests/unit/components.test.ts` passed: 2 files, 8 tests.
- **Final verification:** `npm run test:unit` passed: 7 files, 35 tests; `npm run typecheck` passed; `git diff --check` passed.
## Component and accessibility decisions
- `createApp(inputs)` creates one SSR-capable app per input snapshot and provides the readonly inputs; the server renders a fresh app and the client hydrates only `window.__PORTFOLIO_INPUTS__`, without a content fetch.
- `AppShell` provides header, labelled native-link navigation, main, eight ordered sections, and a footer-scoped contact destination. It keeps exactly one H1 and uses H2/H3 hierarchy without skips.
- All displayed portfolio facts come from typed content props. Private project articles have no anchors; public URLs receive `target="_blank"` with `rel="noopener noreferrer"`.
- The infrastructure figure contains an inline named/described SVG, visible caption, and a visible text-list equivalent sourced from safe evidence bands. The SVG does not introduce topology identifiers.
- Toolkit placeholders are assetless and `aria-hidden`; each technology’s visible label supplies the only announced name.
## Self-review
- Verified no scene, audio, canvas, remote fetch, portrait, CV download, fake-metric, distro-label, or private-link code was introduced in the Task 4 sources.
- The supplied public content fixture remains the only subject-matter source; destination identity, visible email, CV message, and verified social links render in the footer.
## Commit
`feat: render semantic portfolio document`
## Concerns
None for this foundation scope. The future Start-the-Drive control and atmospheric destination treatment remain intentionally outside this semantic-only task.

10
src/App.vue Normal file
View File

@@ -0,0 +1,10 @@
<script setup lang="ts">
import AppShell from './components/AppShell.vue';
import type { ProductionInputs } from './content/types';
defineProps<{ inputs: Readonly<ProductionInputs> }>();
</script>
<template>
<AppShell :content="inputs.content" />
</template>

11
src/app.ts Normal file
View File

@@ -0,0 +1,11 @@
import { createSSRApp, type InjectionKey } from 'vue';
import App from './App.vue';
import type { ProductionInputs } from './content/types';
export const productionInputsKey: InjectionKey<Readonly<ProductionInputs>> = Symbol('production-inputs');
export function createApp(inputs: Readonly<ProductionInputs>) {
const app = createSSRApp(App, { inputs });
app.provide(productionInputsKey, inputs);
return app;
}

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import ContactDestination from './ContactDestination.vue';
import ExperienceTimeline from './ExperienceTimeline.vue';
import InfrastructureDiagram from './InfrastructureDiagram.vue';
import PortfolioSection from './PortfolioSection.vue';
import ProjectCard from './ProjectCard.vue';
import ProjectCaseStudy from './ProjectCaseStudy.vue';
import SiteNavigation from './SiteNavigation.vue';
import ToolkitGrid from './ToolkitGrid.vue';
import type { PortfolioContent } from '@/content/types';
defineProps<{ content: Readonly<PortfolioContent> }>();
</script>
<template>
<a class="skip-link" href="#portfolio-content">Skip to portfolio content</a>
<header>
<h1>{{ content.identity.name }}</h1>
<p>{{ content.identity.role }}</p>
<SiteNavigation :items="content.navigation" />
</header>
<main id="portfolio-content">
<PortfolioSection id="start" :index="1" :eyebrow="content.navigation[0]!.label.toUpperCase()" :heading="content.navigation[0]!.label.toUpperCase()">
<p>{{ content.identity.valueProposition }}</p>
</PortfolioSection>
<PortfolioSection id="profile" :index="2" :eyebrow="content.navigation[1]!.label.toUpperCase()" :heading="content.navigation[1]!.label.toUpperCase()">
<p>{{ content.profile.introduction }}</p>
<dl>
<template v-for="fact in content.profile.screeningFacts" :key="fact.label">
<dt>{{ fact.label }}</dt>
<dd>{{ fact.value }}</dd>
</template>
</dl>
</PortfolioSection>
<PortfolioSection id="experience" :index="3" :eyebrow="content.navigation[2]!.label.toUpperCase()" :heading="content.navigation[2]!.label.toUpperCase()">
<ExperienceTimeline :experience="content.experience" :systems-operations="content.systemsOperations" />
</PortfolioSection>
<PortfolioSection id="infrastructure" :index="4" :eyebrow="content.navigation[3]!.label.toUpperCase()" :heading="content.navigation[3]!.label.toUpperCase()">
<InfrastructureDiagram :infrastructure="content.infrastructure" />
</PortfolioSection>
<PortfolioSection id="selected-builds" :index="5" :eyebrow="content.navigation[4]!.label.toUpperCase()" :heading="content.navigation[4]!.label.toUpperCase()">
<ProjectCaseStudy v-for="project in content.projects.selected" :key="project.id" :project="project" />
</PortfolioSection>
<PortfolioSection id="additional-work" :index="6" :eyebrow="content.navigation[5]!.label.toUpperCase()" :heading="content.navigation[5]!.label.toUpperCase()">
<ProjectCard v-for="project in content.projects.additional" :key="project.id" :project="project" />
</PortfolioSection>
<PortfolioSection id="toolkit" :index="7" :eyebrow="content.navigation[6]!.label.toUpperCase()" :heading="content.navigation[6]!.label.toUpperCase()">
<ToolkitGrid :groups="content.toolkit" />
</PortfolioSection>
<PortfolioSection id="contact" :index="8" :eyebrow="content.navigation[7]!.label.toUpperCase()" :heading="content.navigation[7]!.label.toUpperCase()">
<footer>
<ContactDestination :contact="content.contact" :identity="content.identity" />
</footer>
</PortfolioSection>
</main>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { ContactContent, PortfolioContent } from '@/content/types';
defineProps<{
contact: Readonly<ContactContent>;
identity: Readonly<Pick<PortfolioContent['identity'], 'name' | 'role'>>;
}>();
</script>
<template>
<p>{{ identity.name }}</p>
<p>{{ identity.role }}</p>
<a :href="`mailto:${contact.email}`">{{ contact.email }}</a>
<p>{{ contact.cvMessage }}</p>
<ul>
<li v-for="link in contact.social" :key="link.service">
<a :href="link.url" target="_blank" rel="noopener noreferrer">{{ link.service }}</a>
</li>
</ul>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { ExperienceRecord, SystemsCaseStudy } from '@/content/types';
defineProps<{
experience: readonly ExperienceRecord[];
systemsOperations: Readonly<SystemsCaseStudy>;
}>();
</script>
<template>
<ol>
<li v-for="record in experience" :key="record.id">
<article>
<h3>{{ record.role }}</h3>
<p>{{ record.organization }} · {{ record.dates }}<template v-if="record.location"> · {{ record.location }}</template></p>
<ul>
<li v-for="highlight in record.highlights" :key="highlight">{{ highlight }}</li>
</ul>
</article>
</li>
</ol>
<article>
<h3>{{ systemsOperations.title }}</h3>
<p>{{ systemsOperations.summary }}</p>
<ul>
<li v-for="item in systemsOperations.evidence" :key="item">{{ item }}</li>
</ul>
</article>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { InfrastructureCaseStudy } from '@/content/types';
defineProps<{ infrastructure: Readonly<InfrastructureCaseStudy> }>();
</script>
<template>
<article>
<h3>{{ infrastructure.title }}</h3>
<p>{{ infrastructure.summary }}</p>
<figure>
<svg
viewBox="0 0 800 240"
role="img"
:aria-labelledby="`${infrastructure.id}-diagram-title`"
:aria-describedby="`${infrastructure.id}-diagram-description`"
>
<title :id="`${infrastructure.id}-diagram-title`">{{ infrastructure.title }}</title>
<desc :id="`${infrastructure.id}-diagram-description`">{{ infrastructure.summary }}</desc>
<g v-for="(band, index) in infrastructure.bands" :key="band.id" :transform="`translate(${40 + index * 190} 70)`">
<rect width="150" height="100" rx="8" />
<text x="75" y="50" text-anchor="middle">{{ band.title }}</text>
</g>
</svg>
<figcaption>{{ infrastructure.title }}</figcaption>
</figure>
<ul data-testid="infrastructure-text-equivalent">
<li v-for="band in infrastructure.bands" :key="band.id">
<strong>{{ band.title }}</strong>: {{ band.summary }}
</li>
</ul>
</article>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
defineProps<{
id: string;
index: number;
eyebrow: string;
heading: string;
}>();
</script>
<template>
<section :id="id" :aria-labelledby="`${id}-heading`" :data-section-index="index">
<p>{{ String(index).padStart(2, '0') }} / {{ eyebrow }}</p>
<h2 :id="`${id}-heading`">{{ heading }}</h2>
<slot />
</section>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { PublicProject } from '@/content/types';
defineProps<{ project: Readonly<PublicProject> }>();
</script>
<template>
<article>
<h3>{{ project.title }}</h3>
<p>{{ project.summary }}</p>
<p>{{ project.contribution }}</p>
<p>{{ project.outcome }}</p>
<a v-if="project.visibility === 'public' && project.url" :href="project.url" target="_blank" rel="noopener noreferrer">{{ project.title }}</a>
</article>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { PublicProject } from '@/content/types';
defineProps<{ project: Readonly<PublicProject> }>();
</script>
<template>
<article>
<h3>{{ project.title }}</h3>
<p>{{ project.summary }}</p>
<dl>
<dt>Problem</dt>
<dd>{{ project.problem }}</dd>
<dt>Contribution</dt>
<dd>{{ project.contribution }}</dd>
<dt>Technical decisions</dt>
<dd>
<ul>
<li v-for="decision in project.technicalDecisions" :key="decision">{{ decision }}</li>
</ul>
</dd>
<dt>Outcome</dt>
<dd>{{ project.outcome }}</dd>
</dl>
<a v-if="project.visibility === 'public' && project.url" :href="project.url" target="_blank" rel="noopener noreferrer">{{ project.title }}</a>
</article>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SectionId } from '@/content/types';
defineProps<{ items: ReadonlyArray<{ id: SectionId; label: string }> }>();
</script>
<template>
<nav aria-label="Portfolio sections">
<ul>
<li v-for="item in items" :key="item.id">
<a :href="`#${item.id}`">{{ item.label }}</a>
</li>
</ul>
</nav>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { ToolkitGroup } from '@/content/types';
defineProps<{ groups: readonly ToolkitGroup[] }>();
</script>
<template>
<div>
<section v-for="group in groups" :key="group.title" :aria-labelledby="`${group.title}-heading`">
<h3 :id="`${group.title}-heading`">{{ group.title }}</h3>
<ul>
<li v-for="technology in group.technologies" :key="technology.name" data-testid="toolkit-technology">
<span aria-hidden="true"></span>
<span>{{ technology.name }}</span>
</li>
</ul>
</section>
</div>
</template>

16
src/entry-client.ts Normal file
View File

@@ -0,0 +1,16 @@
import { createApp } from './app';
import type { ProductionInputs } from './content/types';
declare global {
interface Window {
__PORTFOLIO_INPUTS__?: Readonly<ProductionInputs>;
}
}
const inputs = window.__PORTFOLIO_INPUTS__;
if (inputs === undefined) {
throw new Error('The serialized portfolio input snapshot is unavailable.');
}
createApp(inputs).mount('#app');

7
src/entry-server.ts Normal file
View File

@@ -0,0 +1,7 @@
import { renderToString } from '@vue/server-renderer';
import { createApp } from './app';
import type { ProductionInputs } from './content/types';
export async function render(_url: string, inputs: Readonly<ProductionInputs>) {
return renderToString(createApp(inputs));
}

View File

@@ -0,0 +1,63 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { validateProductionInputs } from '@/content/schemas';
import { render } from '@/entry-server';
const fixture = (name: string): unknown =>
JSON.parse(readFileSync(resolve(process.cwd(), 'tests/fixtures', name), 'utf8'));
const validInputs = validateProductionInputs(
fixture('portfolio-content.valid.json'),
fixture('asset-manifest.valid.json'),
fixture('site-config.preview.json'),
new URL('file:///prepared-assets/'),
);
const renderedDocument = async (): Promise<Document> => {
const document = globalThis.document.implementation.createHTMLDocument('Portfolio');
document.body.innerHTML = await render('/', validInputs);
return document;
};
describe('semantic portfolio document', () => {
it('renders the complete landmark and heading reading structure on the server', async () => {
const document = await renderedDocument();
expect(document.querySelectorAll('header')).toHaveLength(1);
expect(document.querySelector('nav[aria-label="Portfolio sections"]')).not.toBeNull();
expect(document.querySelectorAll('main')).toHaveLength(1);
expect(document.querySelectorAll('footer')).toHaveLength(1);
expect(document.querySelectorAll('h1')).toHaveLength(1);
expect(document.querySelector('h1')?.textContent).toBe('Luca Pîslaru');
expect([...document.querySelectorAll('main > section')].map((section) => section.id)).toEqual([
'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 > h2')].map((heading) => heading.textContent)).toEqual([
'START', 'PROFILE', 'EXPERIENCE', 'INFRASTRUCTURE', 'SELECTED BUILDS', 'ADDITIONAL WORK', 'TOOLKIT', 'CONTACT',
]);
expect(document.querySelector('h2 + h4')).toBeNull();
});
it('keeps native section links and visible contact available without JavaScript', async () => {
const document = await renderedDocument();
const navigation = document.querySelector('nav[aria-label="Portfolio sections"]')!;
expect([...navigation.querySelectorAll('a')].map((link) => link.getAttribute('href'))).toEqual([
'#start', '#profile', '#experience', '#infrastructure', '#selected-builds', '#additional-work', '#toolkit', '#contact',
]);
const email = document.querySelector('a[href="mailto:hello@example.test"]');
expect(email?.textContent).toBe('hello@example.test');
expect(document.body.textContent).toContain('For a current CV or role-specific information, contact me directly.');
});
it('includes systems and software evidence in server-rendered content', async () => {
const document = await renderedDocument();
expect(document.body.textContent).toContain('Professional systems operations');
expect(document.body.textContent).toContain('Secure self-hosted platform and lifecycle');
expect(document.body.textContent).toContain('Private self-hosted medication tracker');
expect(document.body.textContent).toContain('Footy Tracker');
});
});

View File

@@ -0,0 +1,100 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import ContactDestination from '@/components/ContactDestination.vue';
import InfrastructureDiagram from '@/components/InfrastructureDiagram.vue';
import ProjectCaseStudy from '@/components/ProjectCaseStudy.vue';
import ToolkitGrid from '@/components/ToolkitGrid.vue';
import type { ContactContent, InfrastructureCaseStudy, PublicProject, ToolkitGroup } from '@/content/types';
const infrastructure: InfrastructureCaseStudy = {
id: 'safe-infrastructure',
title: 'Secure self-hosted platform and lifecycle',
summary: 'A compact infrastructure case study.',
bands: [
{ id: 'ingress-tls', title: 'Ingress and TLS', summary: 'Managed encrypted ingress.' },
{ id: 'identity-access', title: 'Identity and access', summary: 'Protected service access.' },
],
};
const privateProject: PublicProject = {
id: 'private-work',
title: 'Private self-hosted medication tracker',
visibility: 'private',
maturity: 'active',
summary: 'A private application.',
problem: 'A recurring workflow needs reliable tracking.',
contribution: 'Designed and maintained the application.',
technicalDecisions: ['Used a synchronised client and server design.'],
outcome: 'The work remains private.',
assetIds: [],
};
const publicProject: PublicProject = {
...privateProject,
id: 'public-work',
title: 'Footy Tracker',
visibility: 'public',
url: 'https://github.com/example/footy-tracker',
};
describe('semantic section components', () => {
it('gives the infrastructure graphic an accessible name, description, caption, and visible text equivalent', () => {
const wrapper = mount(InfrastructureDiagram, { props: { infrastructure } });
const svg = wrapper.get('svg');
expect(svg.attributes('role')).toBe('img');
expect(svg.attributes('aria-labelledby')).toContain('safe-infrastructure-diagram-title');
expect(svg.attributes('aria-describedby')).toContain('safe-infrastructure-diagram-description');
expect(wrapper.get('figcaption').text()).toContain('Secure self-hosted platform and lifecycle');
expect(wrapper.get('[data-testid="infrastructure-text-equivalent"]').text()).toContain('Ingress and TLS');
expect(wrapper.get('[data-testid="infrastructure-text-equivalent"]').text()).toContain('Identity and access');
});
it('does not turn private project content into a link container', () => {
const wrapper = mount(ProjectCaseStudy, { props: { project: privateProject } });
expect(wrapper.get('article').findAll('a')).toHaveLength(0);
expect(wrapper.get('h3').text()).toBe('Private self-hosted medication tracker');
});
it('uses a safe external link only for a public project with an approved URL', () => {
const wrapper = mount(ProjectCaseStudy, { props: { project: publicProject } });
const link = wrapper.get('article a');
expect(link.attributes('href')).toBe('https://github.com/example/footy-tracker');
expect(link.attributes('target')).toBe('_blank');
expect(link.attributes('rel')).toBe('noopener noreferrer');
});
it('announces every toolkit technology once while hiding the decorative icon placeholder', () => {
const groups: readonly ToolkitGroup[] = [{
title: 'Systems & delivery',
technologies: [{ name: 'Linux' }],
}];
const wrapper = mount(ToolkitGrid, { props: { groups } });
const tile = wrapper.get('[data-testid="toolkit-technology"]');
expect(tile.text()).toBe('Linux');
expect(tile.get('[aria-hidden="true"]').attributes('aria-hidden')).toBe('true');
expect(tile.findAll('[aria-label="Linux"]').length).toBe(0);
});
it('renders a visible mailto contact and safe verified social links', () => {
const contact: ContactContent = {
email: 'hello@example.test',
cvMessage: 'For a current CV or role-specific information, contact me directly.',
social: [{ service: 'GitHub', url: 'https://github.com/example' }],
};
const wrapper = mount(ContactDestination, {
props: {
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');
const social = wrapper.get('a[href="https://github.com/example"]');
expect(social.text()).toBe('GitHub');
expect(social.attributes('rel')).toBe('noopener noreferrer');
});
});

View File

@@ -1,7 +1,9 @@
import { defineConfig } from 'vitest/config';
import { fileURLToPath, URL } from 'node:url';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},