From 7d9e3c2eac198f12711656cc5f5a59606a8f120a Mon Sep 17 00:00:00 2001 From: LucaP Date: Fri, 4 Sep 2026 20:49:55 +0300 Subject: [PATCH] fix: restore content compatibility checks --- scripts/verify-artifacts.mjs | 8 +-- src/components/ToolkitGrid.vue | 3 +- src/icons/brand-icons.ts | 57 +++++++++++++++++----- src/styles/components.css | 5 ++ src/styles/layout.css | 6 ++- tests/browser/no-javascript.spec.ts | 13 +++-- tests/browser/semantic.spec.ts | 4 +- tests/integration/prerender.test.ts | 5 +- tests/integration/verify-artifacts.test.ts | 20 ++++++++ tests/unit/ProjectMedia.test.ts | 8 ++- tests/unit/brand-icons.test.ts | 15 +++--- tests/unit/components.test.ts | 10 ++++ tests/unit/metadata.test.ts | 2 +- 13 files changed, 118 insertions(+), 38 deletions(-) diff --git a/scripts/verify-artifacts.mjs b/scripts/verify-artifacts.mjs index a26dd4b..103663f 100644 --- a/scripts/verify-artifacts.mjs +++ b/scripts/verify-artifacts.mjs @@ -242,10 +242,12 @@ const assertSemanticHtml = (html, inputs, contract) => { for (const copy of requiredVisibleCopy) { if (!semanticText.includes(copy)) throw new Error(`Missing visible semantic evidence: ${copy}`); } - const mailLinks = [...appRoot.querySelectorAll(`a[href="mailto:${inputs.content.contact.email}"]`)] + const mailLinks = [...appRoot.querySelectorAll('a[href^="mailto:"]')] .filter((node) => isVisibleSemanticNode(node, appRoot)); - if (mailLinks.length !== 1 || mailLinks[0].textContent !== 'Start a conversation') { - throw new Error('Missing visible semantic mailto contact link.'); + if (mailLinks.length !== 2 || mailLinks.some((link) => + link.getAttribute('href') !== `mailto:${inputs.content.contact.email}` || link.textContent !== 'Email Luca' + )) { + throw new Error('Visible semantic mailto links must be exactly the hero and contact Email Luca actions.'); } const moduleScript = document.querySelector(`script[type="module"][src="/${contract.entryModule}"]`); diff --git a/src/components/ToolkitGrid.vue b/src/components/ToolkitGrid.vue index 8c9bfc7..9f130f2 100644 --- a/src/components/ToolkitGrid.vue +++ b/src/components/ToolkitGrid.vue @@ -1,6 +1,7 @@ @@ -11,7 +12,7 @@ defineProps<{ groups: readonly ToolkitGroup[] }>();

{{ group.title }}

diff --git a/src/icons/brand-icons.ts b/src/icons/brand-icons.ts index 3f10d88..db01e4b 100644 --- a/src/icons/brand-icons.ts +++ b/src/icons/brand-icons.ts @@ -2,19 +2,29 @@ // named exports. Import its ESM entry directly until upstream ships a valid // TypeScript declaration; the selected runtime exports remain tree-shakeable. import { + siAuthelia, + siCpanel, siDocker, + siDrizzle, siDotnet, siFlutter, + siFastify, siGit, siGithub, siGitlab, siJavascript, siLinux, siNginx, + siNextcloud, siOpenjdk, + siOpencv, siPostgresql, siProxmox, siPython, + siPytorch, + siSqlite, + siTypescript, + siUltralytics, siVuedotjs, siWordpress, // @ts-expect-error simple-icons 16.29.0 has no declaration for this ESM file. @@ -29,16 +39,25 @@ export type BrandIconLabel = | 'Linux' | 'Docker' | 'Nginx' - | 'Proxmox / KVM' + | 'cPanel' + | 'Authelia' + | 'Nextcloud' + | 'Proxmox/KVM' | 'Git' | 'Python' + | 'TypeScript/JavaScript' | 'Vue.js' - | 'JavaScript' - | 'C# / .NET' - | 'Flutter / Dart' - | 'PostgreSQL / SQL' + | 'Flutter/Dart' + | 'Fastify' + | 'C#/.NET' | 'Headless WordPress' | 'Java' + | 'PostgreSQL' + | 'Drift/SQLite' + | 'Drizzle ORM' + | 'PyTorch' + | 'Ultralytics/YOLO' + | 'OpenCV' | 'GitHub' | 'GitLab' | 'LinkedIn'; @@ -95,24 +114,33 @@ const linkedIn: BrandIconDefinition = { /** * One canonical icon per visible toolkit/social label. Composite labels use - * the most representative official mark (for example, PostgreSQL for SQL). + * the most representative official mark (for example, SQLite for Drift/SQLite). */ export const brandIcons: Readonly> = { Linux: fromSimpleIcons(siLinux), Docker: fromSimpleIcons(siDocker), Nginx: fromSimpleIcons(siNginx), - 'Proxmox / KVM': fromSimpleIcons(siProxmox), + cPanel: fromSimpleIcons(siCpanel), + Authelia: fromSimpleIcons(siAuthelia), + Nextcloud: fromSimpleIcons(siNextcloud), + 'Proxmox/KVM': fromSimpleIcons(siProxmox), Git: fromSimpleIcons(siGit), Python: fromSimpleIcons(siPython), + 'TypeScript/JavaScript': fromSimpleIcons(siTypescript), 'Vue.js': fromSimpleIcons(siVuedotjs), - JavaScript: fromSimpleIcons(siJavascript), - 'C# / .NET': fromSimpleIcons(siDotnet), - 'Flutter / Dart': fromSimpleIcons(siFlutter), - 'PostgreSQL / SQL': fromSimpleIcons(siPostgresql), + 'Flutter/Dart': fromSimpleIcons(siFlutter), + Fastify: fromSimpleIcons(siFastify), + 'C#/.NET': fromSimpleIcons(siDotnet), 'Headless WordPress': fromSimpleIcons(siWordpress), // Simple Icons does not currently publish a Java export; OpenJDK is the // official open Java platform mark and is the closest canonical asset. Java: fromSimpleIcons(siOpenjdk), + PostgreSQL: fromSimpleIcons(siPostgresql), + 'Drift/SQLite': fromSimpleIcons(siSqlite), + 'Drizzle ORM': fromSimpleIcons(siDrizzle), + PyTorch: fromSimpleIcons(siPytorch), + 'Ultralytics/YOLO': fromSimpleIcons(siUltralytics), + OpenCV: fromSimpleIcons(siOpencv), GitHub: fromSimpleIcons(siGithub), GitLab: fromSimpleIcons(siGitlab), LinkedIn: linkedIn, @@ -120,9 +148,14 @@ export const brandIcons: Readonly> = export const brandIconLabels = Object.freeze(Object.keys(brandIcons) as BrandIconLabel[]); +/** Whether a visible toolkit label has a suitable official icon. */ +export function brandIconAvailable(label: string): label is BrandIconLabel { + return Object.prototype.hasOwnProperty.call(brandIcons, label); +} + /** Resolve a validated content label, failing loudly for an unregistered mark. */ export function brandIconFor(label: string): BrandIconDefinition { - if (!Object.prototype.hasOwnProperty.call(brandIcons, label)) { + if (!brandIconAvailable(label)) { throw new Error(`No official brand icon registered for: ${label}`); } return brandIcons[label as BrandIconLabel]; diff --git a/src/styles/components.css b/src/styles/components.css index 7fd0c58..03d92bc 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -67,6 +67,11 @@ content: none; } +.hero-journey__action--primary:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 3px; +} + .hero-journey__action::after { content: "→"; transition: transform var(--transition); diff --git a/src/styles/layout.css b/src/styles/layout.css index 443e63f..fb81234 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -79,14 +79,16 @@ display: grid; min-block-size: calc(100svh - var(--site-navigation-block-size)); align-items: start; - padding: clamp(3.5rem, 12vh, 8rem) 4vw 16vh; + padding: clamp(2rem, 8vh, 6rem) 4vw 16vh; scroll-margin-block-start: var(--site-navigation-block-size); } .hero-journey__content { + position: relative; + z-index: 1; display: grid; min-inline-size: 0; - inline-size: min(34rem, 39vw); + inline-size: min(38rem, 50vw); gap: 1rem; padding: clamp(1.4rem, 2.2vw, 2.1rem); border: 1px solid var(--line); diff --git a/tests/browser/no-javascript.spec.ts b/tests/browser/no-javascript.spec.ts index e0e18b5..08e1fad 100644 --- a/tests/browser/no-javascript.spec.ts +++ b/tests/browser/no-javascript.spec.ts @@ -36,10 +36,10 @@ const navigationLabels: Readonly> = contact: "Contact", }; -const visibleTextLeafSelector = "h1,h2,h3,p,dt,dd,li,figcaption,a,svg text"; +const visibleTextLeafSelector = "h1,h2,h3,h4,p,dt,dd,li,figcaption,a,svg text"; const visibleTextSelector = [ - ".site-header :is(h1,h2,h3,p,dt,dd,li,figcaption,a)", - ".portfolio-main :is(h1,h2,h3,p,dt,dd,li,figcaption,a)", + ".site-header :is(h1,h2,h3,h4,p,dt,dd,li,figcaption,a)", + ".portfolio-main :is(h1,h2,h3,h4,p,dt,dd,li,figcaption,a)", ".portfolio-main svg text", ].join(","); @@ -145,8 +145,7 @@ const expectedVisibleTextSegments = ( content.infrastructure.featuredOperation.title, content.infrastructure.featuredOperation.context, ...content.infrastructure.featuredOperation.actions, - "Outcome:", - content.infrastructure.featuredOperation.outcome, + `Outcome: ${content.infrastructure.featuredOperation.outcome}`, ]), ...section( "selected-builds", @@ -395,8 +394,8 @@ test("keeps every raw SSR reading-path segment visible without JavaScript", asyn } const email = page.locator('a[href="mailto:luca.pislaru@gmail.com"]'); - await expect(email).toBeVisible(); - await expect(email).toHaveText("Start a conversation"); + await expect(email).toHaveCount(2); + await expect(email).toHaveText(["Email Luca", "Email Luca"]); await expect( page.getByText( "For a current CV or role-specific information, contact me directly.", diff --git a/tests/browser/semantic.spec.ts b/tests/browser/semantic.spec.ts index c565b00..d5e30ec 100644 --- a/tests/browser/semantic.spec.ts +++ b/tests/browser/semantic.spec.ts @@ -153,8 +153,8 @@ test('keeps canonical native anchors and safe external destinations', async ({ const email = page.locator('a[href^="mailto:"]'); await expect(email).toHaveCount(2); await expect(email).toHaveText(['Email Luca', 'Email Luca']); - expect(await email.evaluateAll((links) => new Set(links.map((link) => link.getAttribute('href'))))).toEqual( - new Set(['mailto:luca.pislaru@gmail.com']), + expect(await email.evaluateAll((links) => [...new Set(links.map((link) => link.getAttribute('href')))])).toEqual( + ['mailto:luca.pislaru@gmail.com'], ); const externalLinks = page.locator('#contact a[target="_blank"]'); diff --git a/tests/integration/prerender.test.ts b/tests/integration/prerender.test.ts index 7b7d91a..9f3b834 100644 --- a/tests/integration/prerender.test.ts +++ b/tests/integration/prerender.test.ts @@ -67,8 +67,9 @@ describe('raw prerendered document', () => { 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('Linux'); - expect(document.querySelector('a[href="mailto:luca.pislaru@gmail.com"]')?.textContent) - .toBe('Start a conversation'); + const mailLinks = document.querySelectorAll('a[href="mailto:luca.pislaru@gmail.com"]'); + expect(mailLinks).toHaveLength(2); + expect([...mailLinks].map((link) => link.textContent)).toEqual(['Email Luca', 'Email Luca']); expect(document.querySelector('nav a[href="#infrastructure"]')).not.toBeNull(); expect(snapshot).not.toBeNull(); expect(moduleScript).not.toBeNull(); diff --git a/tests/integration/verify-artifacts.test.ts b/tests/integration/verify-artifacts.test.ts index 0912114..cf149f9 100644 --- a/tests/integration/verify-artifacts.test.ts +++ b/tests/integration/verify-artifacts.test.ts @@ -118,6 +118,26 @@ describe('artifact verification', () => { await expect(verifyArtifactDirectory(directory, inputs, contract)).resolves.toBeUndefined(); }); + it.each([ + { + name: 'a missing hero mailto link', + mutate: (html: string) => html.replace('href="mailto:luca.pislaru@gmail.com">Email Luca', 'href="#selected-builds">Email Luca'), + }, + { + name: 'a duplicate mailto link beyond the hero and contact actions', + mutate: (html: string) => html.replace('', 'Email Luca'), + }, + { + name: 'a mailto link with an address that differs from public content', + mutate: (html: string) => html.replace('href="mailto:luca.pislaru@gmail.com">Email Luca', 'href="mailto:wrong@example.test">Email Luca'), + }, + ])('rejects $name', async ({ mutate }) => { + const { directory, html, inputs, contract } = await writeValidArtifacts(); + await writeFile(join(directory, 'index.html'), mutate(html)); + + await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/mailto/i); + }); + it.each([ { name: 'an omitted adaptive soundtrack file', diff --git a/tests/unit/ProjectMedia.test.ts b/tests/unit/ProjectMedia.test.ts index f0e5109..f956c50 100644 --- a/tests/unit/ProjectMedia.test.ts +++ b/tests/unit/ProjectMedia.test.ts @@ -234,7 +234,10 @@ describe('useLazyMedia', () => { describe('media content schema', () => { it('requires content-owned descriptions and exact project asset closure', () => { const raw = JSON.parse(JSON.stringify({ - identity: { name: 'Luca Pîslaru', role: 'System Administrator & Full-Stack Developer', valueProposition: 'Value.' }, + identity: { + name: 'Luca Pîslaru', role: 'System Administrator & Full-Stack Developer', valueProposition: 'Value.', + location: { city: 'Bucharest', country: 'Romania', countryCode: 'RO' }, availability: 'Available remotely.', + }, navigation: ['start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact'] .map((id) => ({ id, label: id })), profile: { introduction: 'Profile.', screeningFacts: [] }, @@ -243,6 +246,7 @@ describe('media content schema', () => { infrastructure: { id: 'infra', title: 'Secure self-hosted platform and lifecycle', summary: 'Summary.', bands: ['one', 'two', 'three', 'four'].map((id) => ({ id, title: id, summary: 'Summary.' })), + featuredOperation: { title: 'Migration', context: 'Context.', actions: ['Prepare.', 'Move.', 'Validate.'], outcome: 'Completed.' }, }, projects: { selected: [ @@ -253,7 +257,7 @@ describe('media content schema', () => { additional: [{ id: 'extra', title: 'Extra', visibility: 'public', maturity: 'learning', summary: 'S.', problem: 'P.', contribution: 'C.', technicalDecisions: ['D.'], outcome: 'O.', assetIds: [], media: [] }], }, toolkit: [{ title: 'Tools', technologies: [{ name: 'Linux' }] }], - contact: { email: 'hello@example.test', social: [], cvMessage: 'Contact directly.' }, + contact: { introduction: 'Open to roles.', email: 'hello@example.test', social: [], cvMessage: 'Contact directly.' }, metadata: { title: 'Title.', description: 'Description.' }, sceneDescription: 'Scene.', })); diff --git a/tests/unit/brand-icons.test.ts b/tests/unit/brand-icons.test.ts index 646a667..86ccb36 100644 --- a/tests/unit/brand-icons.test.ts +++ b/tests/unit/brand-icons.test.ts @@ -2,14 +2,14 @@ import { mount } from '@vue/test-utils'; import { describe, expect, it } from 'vitest'; import BrandIcon from '@/components/BrandIcon.vue'; import content from '@/../content/public/portfolio-content.json'; -import { brandIconFor, brandIconLabels } from '@/icons/brand-icons'; +import { brandIconAvailable, brandIconFor, brandIconLabels } from '@/icons/brand-icons'; const toolkitLabels = content.toolkit.flatMap((group) => group.technologies.map((technology) => technology.name)); const socialLabels = content.contact.social.map((link) => link.service); describe('official brand icon registry', () => { - it('resolves every toolkit technology in validated portfolio content', () => { - for (const label of toolkitLabels) { + it('resolves every toolkit technology with an honest official mark', () => { + for (const label of toolkitLabels.filter(brandIconAvailable)) { const icon = brandIconFor(label); expect(icon.title).toBeTruthy(); expect(icon.hex).toMatch(/^[0-9A-F]{6}$/u); @@ -27,10 +27,13 @@ describe('official brand icon registry', () => { it('keeps the registry closed over all content-facing labels', () => { expect(brandIconLabels).toEqual([ - 'Linux', 'Docker', 'Nginx', 'Proxmox / KVM', 'Git', 'Python', 'Vue.js', - 'JavaScript', 'C# / .NET', 'Flutter / Dart', 'PostgreSQL / SQL', - 'Headless WordPress', 'Java', 'GitHub', 'GitLab', 'LinkedIn', + 'Linux', 'Docker', 'Nginx', 'cPanel', 'Authelia', 'Nextcloud', 'Proxmox/KVM', + 'Git', 'Python', 'TypeScript/JavaScript', 'Vue.js', 'Flutter/Dart', 'Fastify', + 'C#/.NET', 'Headless WordPress', 'Java', 'PostgreSQL', 'Drift/SQLite', + 'Drizzle ORM', 'PyTorch', 'Ultralytics/YOLO', 'OpenCV', 'GitHub', 'GitLab', 'LinkedIn', ]); + expect(['DNS', 'TLS', 'Fail2Ban', 'Btrfs', 'rsync', 'SQL', 'SPSS', 'Microsoft Excel', 'SoccerNet'] + .every((label) => !brandIconAvailable(label))).toBe(true); expect(() => brandIconFor('Unlisted technology')).toThrow(/No official brand icon/u); }); diff --git a/tests/unit/components.test.ts b/tests/unit/components.test.ts index 8bc6ff4..be408cf 100644 --- a/tests/unit/components.test.ts +++ b/tests/unit/components.test.ts @@ -163,6 +163,16 @@ describe("semantic section components", () => { expect(tile.findAll('[aria-label="Linux"]').length).toBe(0); }); + it("renders a generic toolkit concept as visible text without inventing an icon", () => { + const wrapper = mount(ToolkitGrid, { + props: { groups: [{ title: "Systems", technologies: [{ name: "DNS" }] }] }, + }); + const tile = wrapper.get('[data-testid="toolkit-technology"]'); + + expect(tile.text()).toBe("DNS"); + expect(tile.find("svg").exists()).toBe(false); + }); + it("renders a visible mailto contact and safe verified social links", () => { const contact: ContactContent = { introduction: "Open to System Administrator and Full-Stack Developer opportunities.", diff --git a/tests/unit/metadata.test.ts b/tests/unit/metadata.test.ts index f9a1134..38eea9c 100644 --- a/tests/unit/metadata.test.ts +++ b/tests/unit/metadata.test.ts @@ -36,7 +36,7 @@ describe('safe metadata', () => { expect(metadata.title).toBe('Luca Pîslaru — System Administrator & Full-Stack Developer'); expect(metadata.description).toBe( - 'Portfolio of Luca Pîslaru, a System Administrator & Full-Stack Developer building and operating reliable systems and software.', + 'Portfolio of Luca Pîslaru, a Bucharest-based System Administrator and Full-Stack Developer working across Linux infrastructure and end-to-end software delivery.', ); expect(metadata.canonicalUrl).toBe('https://portfolio.example.test/'); expect(metadata.openGraph.image).toBeUndefined();