fix: preserve journey geometry and mailto placement

This commit is contained in:
2026-09-04 21:02:44 +03:00
parent 7d9e3c2eac
commit 242d79599a
7 changed files with 44 additions and 11 deletions

View File

@@ -242,12 +242,13 @@ const assertSemanticHtml = (html, inputs, contract) => {
for (const copy of requiredVisibleCopy) { for (const copy of requiredVisibleCopy) {
if (!semanticText.includes(copy)) throw new Error(`Missing visible semantic evidence: ${copy}`); if (!semanticText.includes(copy)) throw new Error(`Missing visible semantic evidence: ${copy}`);
} }
const mailLinks = [...appRoot.querySelectorAll('a[href^="mailto:"]')] const mailLinks = [...document.querySelectorAll('a[href^="mailto:"]')];
.filter((node) => isVisibleSemanticNode(node, appRoot)); const startMailLinks = [...document.querySelectorAll('#start a[href^="mailto:"]')];
if (mailLinks.length !== 2 || mailLinks.some((link) => const contactMailLinks = [...document.querySelectorAll('#contact a[href^="mailto:"]')];
if (mailLinks.length !== 2 || startMailLinks.length !== 1 || contactMailLinks.length !== 1 || mailLinks.some((link) =>
link.getAttribute('href') !== `mailto:${inputs.content.contact.email}` || link.textContent !== 'Email Luca' 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.'); throw new Error('Mailto links must be exactly one Email Luca action beneath each hero and contact section.');
} }
const moduleScript = document.querySelector(`script[type="module"][src="/${contract.entryModule}"]`); const moduleScript = document.querySelector(`script[type="module"][src="/${contract.entryModule}"]`);

View File

@@ -13,7 +13,7 @@ defineProps<{ groups: readonly ToolkitGroup[] }>();
<ul class="toolkit-items"> <ul class="toolkit-items">
<li v-for="technology in group.technologies" :key="technology.name" class="toolkit-item" data-testid="toolkit-technology"> <li v-for="technology in group.technologies" :key="technology.name" class="toolkit-item" data-testid="toolkit-technology">
<BrandIcon v-if="brandIconAvailable(technology.name)" :label="technology.name" /> <BrandIcon v-if="brandIconAvailable(technology.name)" :label="technology.name" />
<span>{{ technology.name }}</span> <span :class="{ 'toolkit-item__label--text-only': !brandIconAvailable(technology.name) }">{{ technology.name }}</span>
</li> </li>
</ul> </ul>
</section> </section>

View File

@@ -21,12 +21,14 @@
max-inline-size: 40ch; max-inline-size: 40ch;
color: var(--text-muted); color: var(--text-muted);
font-size: var(--step-0); font-size: var(--step-0);
line-height: 1.35;
} }
.hero-journey__availability { .hero-journey__availability {
max-inline-size: 52ch; max-inline-size: 52ch;
color: var(--text-muted); color: var(--text-muted);
font-size: var(--step--1); font-size: var(--step--1);
line-height: 1.35;
} }
.hero-journey__actions { .hero-journey__actions {
@@ -36,6 +38,10 @@
align-items: center; align-items: center;
} }
.hero-journey__actions .hero-journey__action {
margin-block-start: 0;
}
.hero-journey__action, .hero-journey__action,
.project-link, .project-link,
.contact-link, .contact-link,
@@ -584,6 +590,10 @@
font-size: var(--step--1); font-size: var(--step--1);
} }
.toolkit-item__label--text-only {
grid-column: 1 / -1;
}
.brand-icon { .brand-icon {
inline-size: 1.05rem; inline-size: 1.05rem;
block-size: 1.05rem; block-size: 1.05rem;

View File

@@ -79,17 +79,14 @@
display: grid; display: grid;
min-block-size: calc(100svh - var(--site-navigation-block-size)); min-block-size: calc(100svh - var(--site-navigation-block-size));
align-items: start; align-items: start;
padding: clamp(2rem, 8vh, 6rem) 4vw 16vh; padding: clamp(3.5rem, 12vh, 8rem) 4vw 16vh;
scroll-margin-block-start: var(--site-navigation-block-size); scroll-margin-block-start: var(--site-navigation-block-size);
} }
.hero-journey__content { .hero-journey__content {
position: relative;
z-index: 1;
display: grid; display: grid;
min-inline-size: 0; inline-size: min(34rem, 39vw);
inline-size: min(38rem, 50vw); gap: 0.75rem;
gap: 1rem;
padding: clamp(1.4rem, 2.2vw, 2.1rem); padding: clamp(1.4rem, 2.2vw, 2.1rem);
border: 1px solid var(--line); border: 1px solid var(--line);
border-inline-start: 3px solid var(--cyan); border-inline-start: 3px solid var(--cyan);

View File

@@ -102,6 +102,20 @@ test('exposes the complete semantic portfolio from the generated document', asyn
await expect(page.getByRole('link', { name: 'Explore work' })).toHaveAttribute('href', '#selected-builds'); await expect(page.getByRole('link', { name: 'Explore work' })).toHaveAttribute('href', '#selected-builds');
await expect(page.getByRole('link', { name: 'Email Luca' })).toHaveCount(2); await expect(page.getByRole('link', { name: 'Email Luca' })).toHaveCount(2);
await expect(page.locator('[data-featured-operation]')).toContainText('without data loss'); await expect(page.locator('[data-featured-operation]')).toContainText('without data loss');
expect(await page.locator('[data-testid="toolkit-technology"]', { hasText: 'DNS' }).locator('span').evaluate(
(label) => getComputedStyle(label).gridColumn,
)).toBe('1 / -1');
expect(await page.locator('.hero-journey').evaluate((hero) => {
const content = hero.querySelector('.hero-journey__content')!;
const heroStyle = getComputedStyle(hero);
const contentStyle = getComputedStyle(content);
return {
preservesPadding: Math.abs(Number.parseFloat(heroStyle.paddingTop) - innerHeight * 0.12) < 0.01,
preservesWidth: Math.abs(Number.parseFloat(contentStyle.width) - Math.min(544, innerWidth * 0.39)) < 0.1,
position: contentStyle.position,
zIndex: contentStyle.zIndex,
};
})).toEqual({ preservesPadding: true, preservesWidth: true, position: 'static', zIndex: 'auto' });
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) =>

View File

@@ -131,6 +131,16 @@ describe('artifact verification', () => {
name: 'a mailto link with an address that differs from public content', 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</a>', 'href="mailto:wrong@example.test">Email Luca</a>'), mutate: (html: string) => html.replace('href="mailto:luca.pislaru@gmail.com">Email Luca</a>', 'href="mailto:wrong@example.test">Email Luca</a>'),
}, },
{
name: 'a hidden mailto link outside the app root',
mutate: (html: string) => html.replace('</body>', '<a hidden href="mailto:luca.pislaru@gmail.com">Email Luca</a></body>'),
},
{
name: 'two mailto links placed beneath contact instead of one per section',
mutate: (html: string) => html
.replace('href="mailto:luca.pislaru@gmail.com">Email Luca</a>', 'href="#selected-builds">Email Luca</a>')
.replace('</footer>', '<a href="mailto:luca.pislaru@gmail.com">Email Luca</a></footer>'),
},
])('rejects $name', async ({ mutate }) => { ])('rejects $name', async ({ mutate }) => {
const { directory, html, inputs, contract } = await writeValidArtifacts(); const { directory, html, inputs, contract } = await writeValidArtifacts();
await writeFile(join(directory, 'index.html'), mutate(html)); await writeFile(join(directory, 'index.html'), mutate(html));

View File

@@ -171,6 +171,7 @@ describe("semantic section components", () => {
expect(tile.text()).toBe("DNS"); expect(tile.text()).toBe("DNS");
expect(tile.find("svg").exists()).toBe(false); expect(tile.find("svg").exists()).toBe(false);
expect(tile.get("span").classes()).toContain("toolkit-item__label--text-only");
}); });
it("renders a visible mailto contact and safe verified social links", () => { it("renders a visible mailto contact and safe verified social links", () => {