diff --git a/.superpowers/sdd/2026-09-01-portfolio-phase-1-foundation/task-4-report.md b/.superpowers/sdd/2026-09-01-portfolio-phase-1-foundation/task-4-report.md new file mode 100644 index 0000000..c08f3fa --- /dev/null +++ b/.superpowers/sdd/2026-09-01-portfolio-phase-1-foundation/task-4-report.md @@ -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. diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..d23654c --- /dev/null +++ b/src/App.vue @@ -0,0 +1,10 @@ + + + diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..dbba906 --- /dev/null +++ b/src/app.ts @@ -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> = Symbol('production-inputs'); + +export function createApp(inputs: Readonly) { + const app = createSSRApp(App, { inputs }); + app.provide(productionInputsKey, inputs); + return app; +} diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue new file mode 100644 index 0000000..4f4bcf6 --- /dev/null +++ b/src/components/AppShell.vue @@ -0,0 +1,64 @@ + + + diff --git a/src/components/ContactDestination.vue b/src/components/ContactDestination.vue new file mode 100644 index 0000000..3ff408a --- /dev/null +++ b/src/components/ContactDestination.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/components/ExperienceTimeline.vue b/src/components/ExperienceTimeline.vue new file mode 100644 index 0000000..ebd2ac2 --- /dev/null +++ b/src/components/ExperienceTimeline.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/InfrastructureDiagram.vue b/src/components/InfrastructureDiagram.vue new file mode 100644 index 0000000..1d70074 --- /dev/null +++ b/src/components/InfrastructureDiagram.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/PortfolioSection.vue b/src/components/PortfolioSection.vue new file mode 100644 index 0000000..fcda921 --- /dev/null +++ b/src/components/PortfolioSection.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/components/ProjectCard.vue b/src/components/ProjectCard.vue new file mode 100644 index 0000000..91cb181 --- /dev/null +++ b/src/components/ProjectCard.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ProjectCaseStudy.vue b/src/components/ProjectCaseStudy.vue new file mode 100644 index 0000000..f92facc --- /dev/null +++ b/src/components/ProjectCaseStudy.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/SiteNavigation.vue b/src/components/SiteNavigation.vue new file mode 100644 index 0000000..f98eb12 --- /dev/null +++ b/src/components/SiteNavigation.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ToolkitGrid.vue b/src/components/ToolkitGrid.vue new file mode 100644 index 0000000..4cd711d --- /dev/null +++ b/src/components/ToolkitGrid.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/entry-client.ts b/src/entry-client.ts new file mode 100644 index 0000000..5e212fe --- /dev/null +++ b/src/entry-client.ts @@ -0,0 +1,16 @@ +import { createApp } from './app'; +import type { ProductionInputs } from './content/types'; + +declare global { + interface Window { + __PORTFOLIO_INPUTS__?: Readonly; + } +} + +const inputs = window.__PORTFOLIO_INPUTS__; + +if (inputs === undefined) { + throw new Error('The serialized portfolio input snapshot is unavailable.'); +} + +createApp(inputs).mount('#app'); diff --git a/src/entry-server.ts b/src/entry-server.ts new file mode 100644 index 0000000..058d2a9 --- /dev/null +++ b/src/entry-server.ts @@ -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) { + return renderToString(createApp(inputs)); +} diff --git a/tests/unit/AppShell.test.ts b/tests/unit/AppShell.test.ts new file mode 100644 index 0000000..c55a5a6 --- /dev/null +++ b/tests/unit/AppShell.test.ts @@ -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 => { + 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'); + }); +}); diff --git a/tests/unit/components.test.ts b/tests/unit/components.test.ts new file mode 100644 index 0000000..e07cfab --- /dev/null +++ b/tests/unit/components.test.ts @@ -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'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index cf2ca08..5ef219c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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)) }, },