feat: core layout, UI atoms, homepage + behaviour modules

- UI atoms: Chip, Button, SplitCvButton, LangSwitch, ThemeToggle, Card,
  SectionLabel, TraceMotif, FramedImage
- core: Base/Seo/Header/MobileNav/Footer/HintBar/SkipLink/ThemeScript
- homepage sections: Hero, ProofStrip, SkillsGrid, ProjectCards,
  ExperienceTimeline, AboutTeaser, ContactBand + Homepage composition (EN/NO)
- behaviour modules: theme, nav (overlay/hint/copy/header), observer (reveal/spy),
  lightbox, form — progressive enhancement, fail-silent
- SEO head component: meta, hreflang from slug map, JSON-LD, OG references
- move content data to src/data (avoid Astro reserved content-collection folder)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-04 01:20:46 +02:00
parent 2184a1892f
commit 1fce1b3cec
45 changed files with 1891 additions and 27 deletions
+99
View File
@@ -0,0 +1,99 @@
/*
Contact form (A9). Loaded only on the contact page. Client validation + honeypot +
time-trap, POST to the relay, then success/error panel swap. Progressive enhancement
over a visible e-mail address (the form degrades to mailto if this never runs).
*/
const form = document.getElementById('contact-form') as HTMLFormElement | null;
if (form) {
const loadedAt = Date.now();
const successPanel = document.getElementById('form-success');
const errorPanel = document.getElementById('form-error');
const submitBtn = form.querySelector<HTMLButtonElement>('button[type="submit"]');
const submitLabel = submitBtn?.querySelector<HTMLElement>('[data-label]');
const sendingText = submitBtn?.dataset.sending ?? 'Sending';
const sendText = submitLabel?.textContent ?? 'Send';
const msgRequired = form.dataset.msgRequired ?? 'Required';
const msgEmail = form.dataset.msgEmail ?? 'Invalid e-mail';
function fieldError(name: string, message: string | null): void {
const field = form!.querySelector<HTMLElement>(`[name="${name}"]`);
const err = form!.querySelector<HTMLElement>(`[data-error-for="${name}"]`);
if (field) field.setAttribute('aria-invalid', message ? 'true' : 'false');
if (err) {
err.textContent = message ?? '';
err.hidden = !message;
}
}
function validate(data: Record<string, string>): boolean {
let ok = true;
let firstInvalid: string | null = null;
const require = (name: string) => {
if (!data[name]?.trim()) {
fieldError(name, msgRequired);
ok = false;
firstInvalid ??= name;
} else {
fieldError(name, null);
}
};
require('name');
if (!data.email?.trim()) {
fieldError('email', msgRequired);
ok = false;
firstInvalid ??= 'email';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
fieldError('email', msgEmail);
ok = false;
firstInvalid ??= 'email';
} else {
fieldError('email', null);
}
require('message');
if (firstInvalid) form!.querySelector<HTMLElement>(`[name="${firstInvalid}"]`)?.focus();
return ok;
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(form);
const data = {
name: String(fd.get('name') ?? ''),
email: String(fd.get('email') ?? ''),
message: String(fd.get('message') ?? ''),
company: String(fd.get('company') ?? ''), // honeypot
};
// Honeypot filled or submitted implausibly fast → treat as bot, feign success.
if (data.company || Date.now() - loadedAt < 2000) {
form.hidden = true;
successPanel?.removeAttribute('hidden');
return;
}
if (!validate(data)) return;
submitBtn?.setAttribute('disabled', 'true');
if (submitLabel) submitLabel.textContent = sendingText;
submitBtn?.classList.add('is-sending');
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: data.name, email: data.email, message: data.message }),
});
if (!res.ok) throw new Error(String(res.status));
form.hidden = true;
successPanel?.removeAttribute('hidden');
} catch {
errorPanel?.removeAttribute('hidden');
} finally {
submitBtn?.removeAttribute('disabled');
submitBtn?.classList.remove('is-sending');
if (submitLabel) submitLabel.textContent = sendText;
}
});
}