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:
@@ -0,0 +1,83 @@
|
||||
---
|
||||
import { useDict, interpolate } from '@i18n/t';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { pathFor, type PageId } from '@i18n/slugMap';
|
||||
import { getProfile } from '@lib/content';
|
||||
import LangSwitch from '@components/ui/LangSwitch.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pageId: PageId;
|
||||
}
|
||||
const { locale, pageId } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const p = getProfile(locale);
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const nav: { id: PageId; label: string }[] = [
|
||||
{ id: 'projects', label: d.nav.projects },
|
||||
{ id: 'experience', label: d.nav.experience },
|
||||
{ id: 'about', label: d.nav.about },
|
||||
{ id: 'contact', label: d.nav.contact },
|
||||
];
|
||||
---
|
||||
|
||||
<footer class="mt-24 border-t border-line bg-surface-1">
|
||||
<div class="mx-auto grid max-w-[--content-max] gap-10 px-6 py-14 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="lg:col-span-2">
|
||||
<p class="font-display text-h4 font-semibold text-ink">Connor Babbington</p>
|
||||
<p class="mt-2 max-w-sm text-small text-ink-muted">{d.footer.tagline}</p>
|
||||
<p class="mt-1 max-w-sm text-small text-ink-muted">{d.footer.availability}</p>
|
||||
</div>
|
||||
|
||||
<nav aria-label={d.a11y.primaryNav}>
|
||||
<p class="mono-label mb-3">{d.nav.projects}</p>
|
||||
<ul class="flex flex-col gap-2 text-small text-ink-muted">
|
||||
{
|
||||
nav.map((item) => (
|
||||
<li>
|
||||
<a class="transition-colors hover:text-ink" href={pathFor(item.id, locale)}>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
<p class="mono-label mb-3">Contact</p>
|
||||
<ul class="flex flex-col gap-2 font-mono text-mono-meta text-ink-muted">
|
||||
<li>
|
||||
<a class="transition-colors hover:text-ink" href={`mailto:${p.links.email}`}
|
||||
>{p.links.email}</a
|
||||
>
|
||||
</li>
|
||||
<li><a class="transition-colors hover:text-ink" href={p.links.linkedin} rel="me noopener">LinkedIn ↗</a></li>
|
||||
<li>
|
||||
<a class="transition-colors hover:text-ink" href={p.links.gitea} rel="me noopener"
|
||||
>git.cesnimda.uk ↗</a
|
||||
>
|
||||
</li>
|
||||
<li><a class="transition-colors hover:text-ink" href={pathFor('cv', locale)}>{d.cv.download}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mx-auto flex max-w-[--content-max] flex-col items-start justify-between gap-4 border-t border-line px-6 py-6 sm:flex-row sm:items-center"
|
||||
>
|
||||
<p class="font-mono text-mono-meta text-ink-faint">
|
||||
{interpolate(d.footer.copyright, { year })}
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href={pathFor('colophon', locale)}
|
||||
class="font-mono text-mono-meta text-ink-muted transition-colors hover:text-ink"
|
||||
>
|
||||
{d.footer.colophon}
|
||||
</a>
|
||||
<LangSwitch locale={locale} pageId={pageId} />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
import { useDict } from '@i18n/t';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { pathFor, type PageId, PROJECT_PAGE_IDS } from '@i18n/slugMap';
|
||||
import LangSwitch from '@components/ui/LangSwitch.astro';
|
||||
import ThemeToggle from '@components/ui/ThemeToggle.astro';
|
||||
import SplitCvButton from '@components/ui/SplitCvButton.astro';
|
||||
import MobileNav from '@components/core/MobileNav.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pageId: PageId;
|
||||
}
|
||||
const { locale, pageId } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
|
||||
const navItems: { id: PageId; label: string }[] = [
|
||||
{ id: 'projects', label: d.nav.projects },
|
||||
{ id: 'experience', label: d.nav.experience },
|
||||
{ id: 'about', label: d.nav.about },
|
||||
{ id: 'contact', label: d.nav.contact },
|
||||
];
|
||||
const isCurrent = (id: PageId) =>
|
||||
id === pageId ||
|
||||
(id === 'projects' && (PROJECT_PAGE_IDS as readonly string[]).includes(pageId));
|
||||
---
|
||||
|
||||
<header
|
||||
id="site-header"
|
||||
class="sticky top-0 z-40 border-b border-transparent bg-surface-0/80 backdrop-blur-md"
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex h-[--header-h] max-w-[--content-max] items-center justify-between px-5 transition-[height] duration-[--dur-standard] ease-[--ease-out] sm:px-6"
|
||||
>
|
||||
{/* Monogram → home */}
|
||||
<a
|
||||
href={pathFor('home', locale)}
|
||||
class="group relative inline-flex h-8 w-8 items-center justify-center rounded-sm border border-line-strong bg-surface-1 font-mono text-small font-bold text-ink"
|
||||
aria-label="Connor Babbington — home"
|
||||
>
|
||||
CB
|
||||
<span class="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 rounded-full bg-accent"></span>
|
||||
</a>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav class="hidden md:block" aria-label={d.a11y.primaryNav}>
|
||||
<ul class="flex items-center gap-7">
|
||||
{
|
||||
navItems.map((item) => (
|
||||
<li>
|
||||
<a
|
||||
href={pathFor(item.id, locale)}
|
||||
class="nav-link text-small text-ink-muted transition-colors duration-[--dur-quick] hover:text-ink"
|
||||
aria-current={isCurrent(item.id) ? 'page' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Desktop right cluster */}
|
||||
<div class="hidden items-center gap-3 md:flex">
|
||||
<LangSwitch locale={locale} pageId={pageId} />
|
||||
<ThemeToggle locale={locale} />
|
||||
<SplitCvButton locale={locale} />
|
||||
</div>
|
||||
|
||||
{/* Mobile cluster */}
|
||||
<div class="flex items-center gap-2 md:hidden">
|
||||
<a
|
||||
href={pathFor('cv', locale)}
|
||||
class="inline-flex h-9 items-center rounded-sm bg-accent px-3 text-small font-semibold text-accent-ink"
|
||||
aria-label={d.cv.download}
|
||||
>
|
||||
CV
|
||||
</a>
|
||||
<button
|
||||
id="mobile-nav-toggle"
|
||||
type="button"
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-sm border border-line text-ink"
|
||||
aria-label={d.a11y.openMenu}
|
||||
aria-controls="mobile-nav"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MobileNav locale={locale} pageId={pageId} />
|
||||
|
||||
<style>
|
||||
#site-header[data-scrolled] {
|
||||
--header-h: var(--header-h-scrolled);
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
.nav-link[aria-current='page'] {
|
||||
color: var(--ink);
|
||||
}
|
||||
.nav-link {
|
||||
position: relative;
|
||||
}
|
||||
.nav-link[aria-current='page']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -6px;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
/*
|
||||
First-visit language hint (ROUTING_SPEC §3, MOCKUPS M5e). Rendered only on EN pages,
|
||||
hidden by default; nav.ts reveals it when the browser prefers Norwegian and it hasn't
|
||||
been dismissed. Never auto-redirects. Text is Norwegian (invites a NO-preferring reader).
|
||||
*/
|
||||
import { useDict } from '@i18n/t';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { pathFor, type PageId } from '@i18n/slugMap';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pageId: PageId;
|
||||
}
|
||||
const { locale, pageId } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
---
|
||||
|
||||
<div id="lang-hint" hidden class="border-b border-line bg-surface-2">
|
||||
<div
|
||||
class="mx-auto flex max-w-[--content-max] items-center gap-4 px-6 py-2.5 text-small text-ink"
|
||||
>
|
||||
<span>{d.hint.text}</span>
|
||||
<a
|
||||
href={pathFor(pageId, 'no')}
|
||||
class="rounded-sm bg-accent px-3 py-1 text-mono-label font-semibold text-accent-ink"
|
||||
>
|
||||
{d.hint.action}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
data-hint-dismiss
|
||||
class="ml-auto text-ink-muted hover:text-ink"
|
||||
aria-label={d.hint.dismiss}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
/*
|
||||
Full-screen mobile nav overlay (MOCKUPS M4). Hidden by default; nav.ts toggles
|
||||
[data-open], traps focus, locks body scroll, closes on Esc. Large tap targets.
|
||||
Language switch inside is plain links (works without JS).
|
||||
*/
|
||||
import { useDict } from '@i18n/t';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { pathFor, type PageId } from '@i18n/slugMap';
|
||||
import { getProfile } from '@lib/content';
|
||||
import LangSwitch from '@components/ui/LangSwitch.astro';
|
||||
import ThemeToggle from '@components/ui/ThemeToggle.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pageId: PageId;
|
||||
}
|
||||
const { locale, pageId } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const p = getProfile(locale);
|
||||
|
||||
const navItems: { id: PageId; label: string }[] = [
|
||||
{ id: 'home', label: d.nav.home },
|
||||
{ id: 'projects', label: d.nav.projects },
|
||||
{ id: 'experience', label: d.nav.experience },
|
||||
{ id: 'about', label: d.nav.about },
|
||||
{ id: 'contact', label: d.nav.contact },
|
||||
];
|
||||
---
|
||||
|
||||
<div
|
||||
id="mobile-nav"
|
||||
class="fixed inset-0 z-50 hidden flex-col bg-surface-1 px-6 pb-8 pt-4 data-[open]:flex md:hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={d.a11y.primaryNav}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-sm border border-line-strong bg-surface-2 font-mono text-small font-bold text-ink"
|
||||
>CB</span
|
||||
>
|
||||
<button
|
||||
id="mobile-nav-close"
|
||||
type="button"
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-sm border border-line text-ink"
|
||||
aria-label={d.a11y.closeMenu}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center gap-3">
|
||||
<LangSwitch locale={locale} pageId={pageId} />
|
||||
<ThemeToggle locale={locale} />
|
||||
</div>
|
||||
|
||||
<nav class="mt-8 flex flex-col" aria-label={d.a11y.primaryNav}>
|
||||
{
|
||||
navItems.map((item) => (
|
||||
<a
|
||||
href={pathFor(item.id, locale)}
|
||||
class="border-b border-line py-4 font-display text-h3 font-semibold text-ink"
|
||||
aria-current={item.id === pageId ? 'page' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div class="mt-8 flex flex-col gap-3">
|
||||
<a
|
||||
href={p.cv.en.path}
|
||||
download
|
||||
class="rounded-sm bg-accent px-4 py-3 text-center text-body font-semibold text-accent-ink"
|
||||
>
|
||||
{d.cv.download} — {d.cv.english}
|
||||
</a>
|
||||
<a
|
||||
href={p.cv.no.path}
|
||||
download
|
||||
class="rounded-sm border border-line-strong px-4 py-3 text-center text-body text-ink"
|
||||
>
|
||||
{d.cv.download} — {d.cv.norsk}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex flex-col gap-2 pt-8 font-mono text-mono-meta text-ink-muted">
|
||||
<a href={`mailto:${p.links.email}`}>{p.links.email}</a>
|
||||
<a href={p.links.linkedin} rel="me noopener">LinkedIn ↗</a>
|
||||
<a href={p.links.gitea} rel="me noopener">git.cesnimda.uk ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
/* Head metadata: title, description, canonical, hreflang, OpenGraph, JSON-LD (SEO_SPEC). */
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { HTML_LANG, OG_LOCALE, otherLocale } from '@i18n/locales';
|
||||
import { pathFor, type PageId } from '@i18n/slugMap';
|
||||
import { absUrl, alternates, ogImagePath, buildJsonLd } from '@lib/seo';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pageId: PageId;
|
||||
title: string;
|
||||
description: string;
|
||||
ogType?: 'website' | 'profile' | 'article';
|
||||
jsonLdOpts?: {
|
||||
projectName?: string;
|
||||
projectType?: 'SoftwareApplication' | 'SoftwareSourceCode';
|
||||
};
|
||||
}
|
||||
const { locale, pageId, title, description, ogType = 'website', jsonLdOpts } = Astro.props;
|
||||
|
||||
const origin = Astro.site?.href ?? 'https://cesnimda.co.uk';
|
||||
const canonical = absUrl(pathFor(pageId, locale), origin);
|
||||
const alts = alternates(pageId, origin);
|
||||
const ogImage = absUrl(ogImagePath(pageId, locale), origin);
|
||||
const jsonLd = buildJsonLd(pageId, locale, origin, jsonLdOpts);
|
||||
---
|
||||
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
|
||||
{alts.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
|
||||
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:site_name" content="Connor Babbington" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:locale" content={OG_LOCALE[locale]} />
|
||||
<meta property="og:locale:alternate" content={OG_LOCALE[otherLocale(locale)]} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
|
||||
<meta name="robots" content="index,follow,max-image-preview:large" />
|
||||
<meta http-equiv="content-language" content={HTML_LANG[locale]} />
|
||||
|
||||
{
|
||||
jsonLd.map((obj) => (
|
||||
<script type="application/ld+json" set:html={JSON.stringify(obj)} />
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import { useDict } from '@i18n/t';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
---
|
||||
|
||||
<a href="#main" class="skip-link">{d.a11y.skipToContent}</a>
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
/*
|
||||
No-flash theme init. Runs before paint, sets data-theme from stored preference
|
||||
or system (ANIMATION_SPEC — the one permitted inline script; CSP-hashed at the
|
||||
nginx layer). First visit follows the system; once set, the choice persists.
|
||||
*/
|
||||
---
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t !== 'light' && t !== 'dark') {
|
||||
t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
document.documentElement.dataset.theme = t;
|
||||
} catch (e) {
|
||||
document.documentElement.dataset.theme = 'dark';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import { homeAnchor, pathFor } from '@i18n/slugMap';
|
||||
import type { ProfileVM } from '@lib/content';
|
||||
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
profile: ProfileVM;
|
||||
}
|
||||
const { locale, profile } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
---
|
||||
|
||||
<section id={homeAnchor('about', locale)} class="mx-auto max-w-[--content-max] px-6 pt-24" data-reveal>
|
||||
<SectionLabel number="04" text={d.home.aboutLabel} />
|
||||
<div class="mt-4 grid gap-8 lg:grid-cols-12">
|
||||
<div class="lg:col-span-8">
|
||||
<h2 class="text-h2">{profile.aboutHeading}</h2>
|
||||
<p class="mt-4 max-w-[64ch] text-body-lg text-ink-muted">{profile.aboutParagraphs[0]}</p>
|
||||
<a
|
||||
href={pathFor('about', locale)}
|
||||
class="mt-6 inline-flex items-center gap-2 text-body text-accent hover:underline"
|
||||
>
|
||||
{profile.aboutHeading} →
|
||||
</a>
|
||||
</div>
|
||||
<div class="lg:col-span-4">
|
||||
<p class="font-mono text-mono-label font-medium uppercase tracking-[0.08em] text-ink-faint">
|
||||
Languages
|
||||
</p>
|
||||
<ul class="mt-3 flex flex-col gap-2">
|
||||
{
|
||||
profile.languages.map((lang) => (
|
||||
<li class="text-body text-ink">
|
||||
{lang.label} <span class="text-ink-muted">— {lang.level}</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import { homeAnchor, pathFor } from '@i18n/slugMap';
|
||||
import type { ProfileVM } from '@lib/content';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
profile: ProfileVM;
|
||||
}
|
||||
const { locale, profile } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
---
|
||||
|
||||
<section id={homeAnchor('contact', locale)} class="mx-auto max-w-[--content-max] px-6 pt-24" data-reveal>
|
||||
<div class="rounded-lg border border-line bg-surface-2 p-8 md:p-12">
|
||||
<h2 class="max-w-[24ch] text-h2">{profile.contactHeading}</h2>
|
||||
<p class="mt-3 text-body-lg text-ink-muted">{profile.contactBody}</p>
|
||||
<div class="mt-7 flex flex-wrap items-center gap-4">
|
||||
<a
|
||||
href={`mailto:${profile.links.email}`}
|
||||
data-copy={profile.links.email}
|
||||
data-copied-label={d.form.copied}
|
||||
class="inline-flex items-center gap-2 rounded-sm bg-accent px-5 py-3 text-body font-semibold text-accent-ink transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
||||
>
|
||||
<span data-copy-label>{profile.links.email}</span>
|
||||
<span aria-hidden="true">⧉</span>
|
||||
</a>
|
||||
<a
|
||||
href={pathFor('contact', locale)}
|
||||
class="inline-flex items-center gap-2 text-body text-ink-muted hover:text-ink"
|
||||
>
|
||||
{d.nav.contact} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import { homeAnchor } from '@i18n/slugMap';
|
||||
import { getExperience } from '@lib/content';
|
||||
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
compact?: boolean; // when true (experience page), render heading differently
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const items = getExperience(locale);
|
||||
const firstAlongside = items.findIndex((i) => i.alongside);
|
||||
|
||||
const period = (from: number, to: number | null) => `${from}–${to ?? d.home.present}`;
|
||||
---
|
||||
|
||||
<section id={homeAnchor('experience', locale)} class="mx-auto max-w-[--content-max] px-6 pt-24" data-reveal>
|
||||
<SectionLabel number="03" text={d.home.experienceLabel} />
|
||||
<h2 class="mt-4 text-h2">{d.home.experienceHeading}</h2>
|
||||
|
||||
<div class="relative mt-10 pl-8">
|
||||
<span
|
||||
class="timeline-spine absolute bottom-2 left-[5px] top-2 w-px origin-top bg-line-strong"
|
||||
aria-hidden="true"></span>
|
||||
|
||||
{
|
||||
items.map((item, i) => (
|
||||
<>
|
||||
{i === firstAlongside && (
|
||||
<p class="mb-3 mt-6 font-mono text-mono-label font-medium uppercase tracking-[0.08em] text-ink-faint">
|
||||
{d.home.earlierRoles}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{item.emphasis === 'featured' ? (
|
||||
<div class="relative pb-10">
|
||||
<span class="absolute -left-8 top-1.5 h-2.5 w-2.5 rounded-full bg-accent ring-4 ring-surface-0" aria-hidden="true" />
|
||||
<h3 class="text-h4 font-semibold text-ink">
|
||||
{item.role} — {item.employer}
|
||||
</h3>
|
||||
<p class="mt-1 font-mono text-mono-meta text-ink-faint">
|
||||
{period(item.period.from, item.period.to)} · {item.location}
|
||||
{item.progression &&
|
||||
` · ${item.progression.map((p) => `${p.from}–${p.to} ${p.label}`).join(' → ')}`}
|
||||
</p>
|
||||
{item.summary && <p class="mt-3 text-body text-ink-muted">{item.summary}</p>}
|
||||
{item.highlights && (
|
||||
<ul class="mt-3 flex flex-col gap-2">
|
||||
{item.highlights.map((h) => (
|
||||
<li class="flex gap-3 text-body text-ink-muted">
|
||||
<span class="mt-2 h-1 w-1 shrink-0 rounded-full bg-accent" aria-hidden="true" />
|
||||
<span>{h}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div class="relative py-2.5">
|
||||
<span class="absolute -left-8 top-4 h-2 w-2 rounded-full bg-ink-faint ring-4 ring-surface-0" aria-hidden="true" />
|
||||
<p class="text-small text-ink-muted">
|
||||
<span class="font-mono text-ink-faint">{period(item.period.from, item.period.to)}</span>
|
||||
<span class="mx-2">·</span>
|
||||
<span class="text-ink">{item.role}</span>
|
||||
<span class="mx-2">·</span>
|
||||
{item.employer}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import { pathFor } from '@i18n/slugMap';
|
||||
import type { ProfileVM } from '@lib/content';
|
||||
import Chip from '@components/ui/Chip.astro';
|
||||
import Button from '@components/ui/Button.astro';
|
||||
import TraceMotif from '@components/ui/TraceMotif.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
profile: ProfileVM;
|
||||
}
|
||||
const { locale, profile } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const isPlaceholder = profile.photo.src.startsWith('placeholder:');
|
||||
---
|
||||
|
||||
<section class="relative overflow-hidden">
|
||||
<div
|
||||
class="dot-grid hero-halo pointer-events-none absolute inset-0 -z-10"
|
||||
style="mask-image: linear-gradient(to bottom, black, transparent 70%); -webkit-mask-image: linear-gradient(to bottom, black, transparent 70%);"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto grid max-w-[--content-max] gap-10 px-6 pb-14 pt-16 lg:grid-cols-12 lg:pt-24">
|
||||
<div class="lg:col-span-7">
|
||||
<p class="font-mono text-mono-label font-medium uppercase tracking-[0.08em] text-accent">
|
||||
{profile.eyebrow}
|
||||
</p>
|
||||
<h1 class="mt-4 text-display tracking-[-0.01em]">{profile.name}</h1>
|
||||
<p class="mt-4 font-display text-h3 font-medium text-ink">{profile.heroTagline}</p>
|
||||
<p class="mt-4 max-w-[52ch] text-body-lg text-ink-muted">{profile.heroSummary}</p>
|
||||
|
||||
<TraceMotif statusLine={profile.statusLine} class="mt-8" />
|
||||
|
||||
<ul class="mt-7 flex flex-wrap gap-2">
|
||||
{profile.chips.map((chip) => <li><Chip kind="fact" label={chip.label} /></li>)}
|
||||
</ul>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-3">
|
||||
<Button variant="primary" href={pathFor('projects', locale)}>{d.hero.viewProjects}</Button>
|
||||
<Button variant="secondary" href={pathFor('cv', locale)}>
|
||||
{d.cv.download} <span class="font-mono text-mono-label opacity-70">· pdf</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-5">
|
||||
<div class="mx-auto max-w-sm">
|
||||
<div class="overflow-hidden rounded-lg border border-line-strong bg-surface-2">
|
||||
{
|
||||
isPlaceholder ? (
|
||||
<div class="dot-grid flex aspect-[4/5] items-center justify-center">
|
||||
<span class="mono-meta text-ink-faint">[ portrait ]</span>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={profile.photo.src}
|
||||
alt={profile.photo.alt}
|
||||
width="800"
|
||||
height="1000"
|
||||
class="aspect-[4/5] w-full object-cover"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<p class="mt-2 font-mono text-mono-meta text-ink-faint">{profile.photo.caption}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
/* Full homepage composition, locale-parameterised so both routes share one source. */
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import Base from '@layouts/Base.astro';
|
||||
import { getProfile, getMeta } from '@lib/content';
|
||||
import Hero from '@components/home/Hero.astro';
|
||||
import ProofStrip from '@components/home/ProofStrip.astro';
|
||||
import SkillsGrid from '@components/home/SkillsGrid.astro';
|
||||
import ProjectCards from '@components/home/ProjectCards.astro';
|
||||
import ExperienceTimeline from '@components/home/ExperienceTimeline.astro';
|
||||
import AboutTeaser from '@components/home/AboutTeaser.astro';
|
||||
import ContactBand from '@components/home/ContactBand.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
const profile = getProfile(locale);
|
||||
const m = getMeta('home', locale);
|
||||
---
|
||||
|
||||
<Base
|
||||
locale={locale}
|
||||
pageId="home"
|
||||
title={m.title}
|
||||
description={m.description}
|
||||
ogType="profile"
|
||||
mainClass="pb-8"
|
||||
>
|
||||
<Hero locale={locale} profile={profile} />
|
||||
<div class="mt-14">
|
||||
<ProofStrip profile={profile} />
|
||||
</div>
|
||||
<SkillsGrid locale={locale} />
|
||||
<ProjectCards locale={locale} />
|
||||
<ExperienceTimeline locale={locale} />
|
||||
<AboutTeaser locale={locale} profile={profile} />
|
||||
<ContactBand locale={locale} profile={profile} />
|
||||
</Base>
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import { homeAnchor } from '@i18n/slugMap';
|
||||
import { getProjectCards } from '@lib/content';
|
||||
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||
import Chip from '@components/ui/Chip.astro';
|
||||
import FramedImage from '@components/ui/FramedImage.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const projects = getProjectCards(locale);
|
||||
const caseStudies = projects.filter((p) => p.template === 'case-study');
|
||||
const capabilities = projects.filter((p) => p.template === 'capability');
|
||||
---
|
||||
|
||||
<section id={homeAnchor('projects', locale)} class="mx-auto max-w-[--content-max] px-6 pt-24" data-reveal>
|
||||
<SectionLabel number="02" text={d.home.projectsLabel} />
|
||||
<h2 class="mt-4 text-h2">{d.home.projectsHeading}</h2>
|
||||
|
||||
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
{
|
||||
caseStudies.map((p) => (
|
||||
<a
|
||||
href={p.path}
|
||||
class="group block overflow-hidden rounded-md border border-line bg-surface-1 transition-colors duration-[--dur-standard] hover:border-line-strong"
|
||||
aria-label={`${p.name} — ${d.project.readCaseStudy}`}
|
||||
>
|
||||
<div class="relative overflow-hidden">
|
||||
{p.heroMedia && (
|
||||
<div class="transition-transform duration-[--dur-standard] group-hover:-translate-y-1">
|
||||
<FramedImage media={p.heroMedia} noZoom class="rounded-none border-0" />
|
||||
</div>
|
||||
)}
|
||||
<div class="absolute right-4 top-4">
|
||||
<Chip kind="status" status={p.status} label={d.project.status[p.status]} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<h3 class="text-h3 font-semibold text-ink">{p.name}</h3>
|
||||
<p class="mt-2 max-w-prose text-body text-ink-muted">{p.cardTeaser}</p>
|
||||
<ul class="mt-4 flex flex-wrap gap-2">
|
||||
{p.stack.slice(0, 5).map((s) => (
|
||||
<li>
|
||||
<Chip kind="stack" label={s.name} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p class="mt-5 inline-flex items-center gap-2 text-body text-accent">
|
||||
{d.project.readCaseStudy}
|
||||
<span class="transition-transform duration-[--dur-quick] group-hover:translate-x-1">→</span>
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
capabilities.map((p) => (
|
||||
<a
|
||||
href={p.path}
|
||||
class="group mt-6 flex flex-col items-start gap-4 rounded-md border border-line bg-surface-1 p-6 transition-colors duration-[--dur-standard] hover:border-line-strong sm:flex-row sm:items-center"
|
||||
>
|
||||
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-sm border border-accent/40 text-accent" aria-hidden="true">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M13.5 6.5H18a2 2 0 0 1 2 2V11" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-h4 font-semibold text-ink">{p.name}</h3>
|
||||
<p class="mt-1 text-small text-ink-muted">{p.cardTeaser}</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-2 text-body text-accent">
|
||||
{d.project.explore}
|
||||
<span class="transition-transform duration-[--dur-quick] group-hover:translate-x-1">→</span>
|
||||
</span>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
import type { ProfileVM } from '@lib/content';
|
||||
|
||||
interface Props {
|
||||
profile: ProfileVM;
|
||||
}
|
||||
const { profile } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="mx-auto max-w-[--content-max] px-6" data-reveal>
|
||||
<ul class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
{
|
||||
profile.proof.map((tile) => (
|
||||
<li class="rounded-md border border-line bg-surface-1 p-5">
|
||||
<p class="font-display text-[22px] font-semibold leading-tight text-ink">{tile.stat}</p>
|
||||
<p class="mt-2 font-mono text-mono-label font-medium uppercase tracking-[0.08em] text-ink-faint">
|
||||
{tile.label}
|
||||
</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import { homeAnchor } from '@i18n/slugMap';
|
||||
import { getSkills } from '@lib/content';
|
||||
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||
import Chip from '@components/ui/Chip.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const groups = getSkills(locale);
|
||||
const num = ['01', '02', '03'];
|
||||
---
|
||||
|
||||
<section id={homeAnchor('skills', locale)} class="mx-auto max-w-[--content-max] px-6 pt-24" data-reveal>
|
||||
<SectionLabel number="01" text={d.home.skillsLabel} />
|
||||
<h2 class="mt-4 text-h2">{d.home.skillsHeading}</h2>
|
||||
<p class="mt-3 max-w-[60ch] text-body text-ink-muted">{d.home.skillsIntro}</p>
|
||||
|
||||
<div class="mt-8 grid gap-5 md:grid-cols-3">
|
||||
{
|
||||
groups.map((group, i) => (
|
||||
<div class="rounded-md border border-line bg-surface-1 p-6 transition-colors duration-[--dur-standard] hover:border-line-strong">
|
||||
<p class="font-mono text-mono-label font-medium uppercase tracking-[0.08em] text-ink-faint">
|
||||
{num[i]} — {group.title}
|
||||
</p>
|
||||
<p class="mt-3 text-small text-ink-muted">{group.context}</p>
|
||||
<ul class="mt-4 flex flex-wrap gap-2">
|
||||
{group.skills.map((s) => (
|
||||
<li>
|
||||
<Chip kind="stack" label={s.name} tooltip={s.tooltip} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
/*
|
||||
Button / link (MICRO_INTERACTIONS). Renders <a> when href is set, else <button>.
|
||||
Motion only transforms/opacity; focus ring comes from the global :focus-visible.
|
||||
*/
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
href?: string;
|
||||
type?: 'button' | 'submit';
|
||||
id?: string;
|
||||
class?: string;
|
||||
download?: boolean;
|
||||
rel?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
const {
|
||||
variant = 'primary',
|
||||
href,
|
||||
type = 'button',
|
||||
id,
|
||||
class: cls,
|
||||
download,
|
||||
rel,
|
||||
ariaLabel,
|
||||
} = Astro.props;
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-2 rounded-sm px-4 py-2.5 text-body font-medium transition-[transform,background-color,filter,color] duration-[--dur-quick] ease-[--ease-out]';
|
||||
const variants = {
|
||||
primary: 'bg-accent text-accent-ink font-semibold hover:-translate-y-px hover:brightness-105',
|
||||
secondary: 'border border-line-strong text-ink hover:bg-surface-1',
|
||||
ghost:
|
||||
'text-ink-muted hover:text-ink underline-offset-4 decoration-accent decoration-2 hover:underline px-2',
|
||||
};
|
||||
const classes = [base, variants[variant], cls];
|
||||
---
|
||||
|
||||
{
|
||||
href ? (
|
||||
<a
|
||||
href={href}
|
||||
id={id}
|
||||
class:list={classes}
|
||||
download={download}
|
||||
rel={rel}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
) : (
|
||||
<button type={type} id={id} class:list={classes} aria-label={ariaLabel}>
|
||||
<slot />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
/*
|
||||
Surface card (DESIGN_SYSTEM §3): flat, hairline border, depth via border-strong on
|
||||
hover — no drop shadow in dark. `interactive` makes the whole card a single link.
|
||||
*/
|
||||
interface Props {
|
||||
href?: string;
|
||||
interactive?: boolean;
|
||||
class?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
const { href, interactive = false, class: cls, ariaLabel } = Astro.props;
|
||||
|
||||
const base = 'rounded-md border border-line bg-surface-1 shadow-[var(--shadow-card)]';
|
||||
const hover = interactive
|
||||
? 'group block transition-colors duration-[--dur-standard] hover:border-line-strong focus-visible:border-line-strong'
|
||||
: '';
|
||||
---
|
||||
|
||||
{
|
||||
href ? (
|
||||
<a href={href} class:list={[base, hover, cls]} aria-label={ariaLabel}>
|
||||
<slot />
|
||||
</a>
|
||||
) : (
|
||||
<div class:list={[base, hover, cls]}>
|
||||
<slot />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
/*
|
||||
Information atom (DESIGN_SYSTEM §2). kinds: fact (non-interactive), stack (with
|
||||
optional tooltip), status (dot + label; accent=active, amber=in development).
|
||||
Status meaning is never colour-only — dot + text always paired (COLOUR_SYSTEM §3.4).
|
||||
*/
|
||||
type Status = 'active' | 'in-development' | 'archived';
|
||||
|
||||
interface Props {
|
||||
kind?: 'fact' | 'stack' | 'status';
|
||||
status?: Status;
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
class?: string;
|
||||
}
|
||||
const { kind = 'fact', status, label, tooltip, class: cls } = Astro.props;
|
||||
|
||||
const base =
|
||||
'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 font-mono text-mono-label whitespace-nowrap';
|
||||
|
||||
const statusRing: Record<Status, string> = {
|
||||
active: 'border-accent/40 text-accent',
|
||||
'in-development': 'border-amber/40 text-amber',
|
||||
archived: 'border-line text-ink-muted',
|
||||
};
|
||||
const dotColor: Record<Status, string> = {
|
||||
active: 'bg-accent',
|
||||
'in-development': 'bg-amber',
|
||||
archived: 'bg-ink-faint',
|
||||
};
|
||||
---
|
||||
|
||||
{
|
||||
kind === 'status' && status ? (
|
||||
<span class:list={[base, statusRing[status], cls]}>
|
||||
<span
|
||||
class:list={[
|
||||
'inline-block h-2 w-2 rounded-full',
|
||||
dotColor[status],
|
||||
status === 'active' && 'status-dot--active',
|
||||
]}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
class:list={[base, 'border-line bg-surface-1 text-ink-muted', cls]}
|
||||
title={tooltip}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
/*
|
||||
Screenshot in a browser-chrome frame (DESIGN_SYSTEM §5). Aspect-locked to prevent
|
||||
layout shift (CLS 0). `placeholder:` sources render a styled skeleton until the real
|
||||
capture lands (Phase 1/2 seeded-data capture prerequisite); real images are lazy and
|
||||
become lightbox targets (bound by lightbox.ts via data-lightbox).
|
||||
*/
|
||||
interface Media {
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
alt: string;
|
||||
caption: string;
|
||||
}
|
||||
interface Props {
|
||||
media: Media;
|
||||
priority?: boolean;
|
||||
index?: number;
|
||||
url?: string;
|
||||
class?: string;
|
||||
/** When true, never render the lightbox button (e.g. inside a card that is itself a link). */
|
||||
noZoom?: boolean;
|
||||
}
|
||||
const { media, priority = false, index, url, class: cls, noZoom = false } = Astro.props;
|
||||
const isPlaceholder = media.src.startsWith('placeholder:');
|
||||
const zoomable = !isPlaceholder && !noZoom && index !== undefined;
|
||||
const ratio = `${media.width} / ${media.height}`;
|
||||
---
|
||||
|
||||
<figure class:list={['overflow-hidden rounded-lg border border-line bg-surface-2', cls]}>
|
||||
{/* Browser chrome */}
|
||||
<div class="flex items-center gap-2 border-b border-line bg-surface-1 px-4 py-2.5">
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-ink-faint"></span>
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-ink-faint"></span>
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-ink-faint"></span>
|
||||
{
|
||||
url && (
|
||||
<span class="mx-auto rounded-full bg-surface-2 px-4 py-0.5 font-mono text-[11px] text-ink-faint">
|
||||
{url}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
isPlaceholder ? (
|
||||
<div class="dot-grid flex items-center justify-center px-6 text-center" style={`aspect-ratio:${ratio}`}>
|
||||
<span class="mono-meta max-w-sm text-ink-faint">{media.caption}</span>
|
||||
</div>
|
||||
) : !zoomable ? (
|
||||
<img
|
||||
src={media.src}
|
||||
width={media.width}
|
||||
height={media.height}
|
||||
alt={media.alt}
|
||||
loading={priority ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
style={`aspect-ratio:${ratio}`}
|
||||
class="w-full"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full cursor-zoom-in"
|
||||
data-lightbox={index}
|
||||
data-lightbox-src={media.src}
|
||||
data-lightbox-caption={media.caption}
|
||||
aria-label={media.alt}
|
||||
>
|
||||
<img
|
||||
src={media.src}
|
||||
width={media.width}
|
||||
height={media.height}
|
||||
alt={media.alt}
|
||||
loading={priority ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
style={`aspect-ratio:${ratio}`}
|
||||
class="w-full"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
<figcaption class="sr-only">{media.alt}</figcaption>
|
||||
</figure>
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
/*
|
||||
Language switch (MICRO_INTERACTIONS): a two-segment control that maps the current
|
||||
page to its twin in the other locale via the slug map. Implemented as two links,
|
||||
so it works with no JavaScript; the active segment is styled as the thumb.
|
||||
*/
|
||||
import { useDict } from '@i18n/t';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { pathFor, type PageId } from '@i18n/slugMap';
|
||||
import { LOCALE_LABEL } from '@i18n/locales';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pageId: PageId;
|
||||
class?: string;
|
||||
}
|
||||
const { locale, pageId, class: cls } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
|
||||
const segments: { code: Locale; href: string; label: string }[] = [
|
||||
{ code: 'en', href: pathFor(pageId, 'en'), label: LOCALE_LABEL.en },
|
||||
{ code: 'no', href: pathFor(pageId, 'no'), label: LOCALE_LABEL.no },
|
||||
];
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={['inline-flex rounded-full border border-line bg-surface-1 p-[3px]', cls]}
|
||||
role="group"
|
||||
aria-label={d.lang.switchTo}
|
||||
>
|
||||
{
|
||||
segments.map((s) =>
|
||||
s.code === locale ? (
|
||||
<span
|
||||
class="rounded-full border border-line-strong bg-surface-2 px-3 py-1 font-mono text-mono-label text-ink"
|
||||
aria-current="true"
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={s.href}
|
||||
class="rounded-full px-3 py-1 font-mono text-mono-label text-ink-muted transition-colors duration-[--dur-quick] hover:text-ink"
|
||||
aria-label={d.lang.switchTo}
|
||||
>
|
||||
{s.label}
|
||||
</a>
|
||||
),
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
/* Numbered mono section eyebrow, e.g. "01 — PROJECTS" (DESIGN_SYSTEM §4). */
|
||||
interface Props {
|
||||
number?: string;
|
||||
text: string;
|
||||
id?: string;
|
||||
}
|
||||
const { number, text, id } = Astro.props;
|
||||
---
|
||||
|
||||
<p
|
||||
id={id}
|
||||
class="font-mono text-mono-label font-medium uppercase tracking-[0.08em] text-accent"
|
||||
>
|
||||
{number ? `${number} — ${text}` : text}
|
||||
</p>
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
/*
|
||||
CV split button (MICRO_INTERACTIONS): primary zone downloads the current-locale CV;
|
||||
the chevron is a native <details> disclosure listing both languages with file sizes
|
||||
— works with no JavaScript. nav.ts enhances it with outside-click / Esc close.
|
||||
*/
|
||||
import { useDict, interpolate } from '@i18n/t';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { getProfile } from '@lib/content';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
compact?: boolean;
|
||||
}
|
||||
const { locale, compact = false } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const p = getProfile(locale);
|
||||
const current = p.cv[locale];
|
||||
const other = locale === 'en' ? p.cv.no : p.cv.en;
|
||||
const otherLabel = locale === 'en' ? d.cv.norsk : d.cv.english;
|
||||
const currentLabel = locale === 'en' ? d.cv.english : d.cv.norsk;
|
||||
---
|
||||
|
||||
<div class="cv-split relative inline-flex items-stretch">
|
||||
<a
|
||||
href={current.path}
|
||||
download
|
||||
class="inline-flex items-center rounded-l-sm bg-accent px-4 py-2 text-small font-semibold text-accent-ink transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
||||
>
|
||||
{d.cv.download}
|
||||
{!compact && <span class="ml-2 font-mono text-mono-label opacity-70">EN/NO</span>}
|
||||
</a>
|
||||
<details class="cv-menu">
|
||||
<summary
|
||||
class="flex cursor-pointer list-none items-center rounded-r-sm border-l border-accent-ink/20 bg-accent px-2 text-accent-ink transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
||||
aria-label={d.cv.download}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</summary>
|
||||
<div
|
||||
class="absolute right-0 top-[calc(100%+8px)] z-30 w-64 overflow-hidden rounded-md border border-line-strong bg-surface-2 shadow-[var(--shadow-overlay)]"
|
||||
>
|
||||
<a
|
||||
href={current.path}
|
||||
download
|
||||
class="flex items-center justify-between gap-2 px-4 py-3 text-small text-ink hover:bg-surface-1"
|
||||
>
|
||||
<span><span class="mr-2 font-mono text-mono-label text-accent">{d.lang[locale]}</span>{currentLabel}</span>
|
||||
<span class="mono-meta">{interpolate(d.cv.fileMeta, { size: current.sizeKb })}</span>
|
||||
</a>
|
||||
<a
|
||||
href={other.path}
|
||||
download
|
||||
class="flex items-center justify-between gap-2 border-t border-line px-4 py-3 text-small text-ink-muted hover:bg-surface-1 hover:text-ink"
|
||||
>
|
||||
<span><span class="mr-2 font-mono text-mono-label">{locale === 'en' ? 'NO' : 'EN'}</span>{otherLabel}</span>
|
||||
<span class="mono-meta">{interpolate(d.cv.fileMeta, { size: other.sizeKb })}</span>
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
import { useDict } from '@i18n/t';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
---
|
||||
|
||||
<button
|
||||
id="theme-toggle"
|
||||
type="button"
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-line text-ink-muted transition-colors duration-[--dur-quick] hover:text-ink"
|
||||
aria-label={d.theme.toLight}
|
||||
data-to-light={d.theme.toLight}
|
||||
data-to-dark={d.theme.toDark}
|
||||
>
|
||||
{/* Moon (shown in dark) / Sun (shown in light) — swapped by CSS on [data-theme]. */}
|
||||
<svg class="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<svg class="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.6"></circle>
|
||||
<path
|
||||
d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
/*
|
||||
The signature "trace" (A1): a thin accent line that draws on load, steps down,
|
||||
and terminates in a lit status dot. Static under reduced motion / repeat visits.
|
||||
*/
|
||||
interface Props {
|
||||
statusLine: string;
|
||||
class?: string;
|
||||
}
|
||||
const { statusLine, class: cls } = Astro.props;
|
||||
---
|
||||
|
||||
<div class:list={['flex items-center gap-3', cls]}>
|
||||
<svg width="200" height="24" viewBox="0 0 200 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
class="trace-line"
|
||||
d="M2 12 H150 l10 10 H194"
|
||||
stroke="var(--accent)"
|
||||
stroke-width="1.5"
|
||||
style="--trace-len: 212"
|
||||
></path>
|
||||
<circle class="trace-dot status-dot--active" cx="196" cy="22" r="4" fill="var(--accent)"
|
||||
></circle>
|
||||
</svg>
|
||||
<span class="mono-meta">{statusLine}</span>
|
||||
</div>
|
||||
@@ -1,6 +1,21 @@
|
||||
import type { Dictionary } from './dictionary';
|
||||
|
||||
export const en: Dictionary = {
|
||||
hero: {
|
||||
viewProjects: 'View projects',
|
||||
},
|
||||
home: {
|
||||
skillsLabel: 'Skills',
|
||||
skillsHeading: 'What I work with',
|
||||
skillsIntro: 'A practical profile: business value, maintainability and dependable delivery.',
|
||||
projectsLabel: 'Projects',
|
||||
projectsHeading: 'Built, shipped and running',
|
||||
experienceLabel: 'Experience',
|
||||
experienceHeading: 'Where I’ve worked',
|
||||
aboutLabel: 'About',
|
||||
earlierRoles: 'Earlier & alongside',
|
||||
present: 'Present',
|
||||
},
|
||||
nav: {
|
||||
home: 'Home',
|
||||
projects: 'Projects',
|
||||
|
||||
@@ -12,6 +12,21 @@ import type { Dictionary } from './dictionary';
|
||||
*/
|
||||
|
||||
export const no: Dictionary = {
|
||||
hero: {
|
||||
viewProjects: 'Se prosjekter',
|
||||
},
|
||||
home: {
|
||||
skillsLabel: 'Kompetanse',
|
||||
skillsHeading: 'Det jeg jobber med',
|
||||
skillsIntro: 'En praktisk profil: forretningsverdi, vedlikeholdbarhet og pålitelig leveranse.',
|
||||
projectsLabel: 'Prosjekter',
|
||||
projectsHeading: 'Bygget, levert og i drift',
|
||||
experienceLabel: 'Erfaring',
|
||||
experienceHeading: 'Hvor jeg har jobbet',
|
||||
aboutLabel: 'Om meg',
|
||||
earlierRoles: 'Tidligere og ved siden av',
|
||||
present: 'nå',
|
||||
},
|
||||
nav: {
|
||||
home: 'Hjem',
|
||||
projects: 'Prosjekter',
|
||||
|
||||
@@ -5,6 +5,21 @@
|
||||
*/
|
||||
|
||||
export interface Dictionary {
|
||||
hero: {
|
||||
viewProjects: string;
|
||||
};
|
||||
home: {
|
||||
skillsLabel: string;
|
||||
skillsHeading: string;
|
||||
skillsIntro: string;
|
||||
projectsLabel: string;
|
||||
projectsHeading: string;
|
||||
experienceLabel: string;
|
||||
experienceHeading: string;
|
||||
aboutLabel: string;
|
||||
earlierRoles: string;
|
||||
present: string;
|
||||
};
|
||||
nav: {
|
||||
home: string;
|
||||
projects: string;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
import '@fontsource-variable/space-grotesk';
|
||||
import '@fontsource-variable/inter';
|
||||
import '@fontsource-variable/jetbrains-mono';
|
||||
import '@styles/global.css';
|
||||
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { HTML_LANG } from '@i18n/locales';
|
||||
import type { PageId } from '@i18n/slugMap';
|
||||
import Seo from '@components/core/Seo.astro';
|
||||
import ThemeScript from '@components/core/ThemeScript.astro';
|
||||
import SkipLink from '@components/core/SkipLink.astro';
|
||||
import Header from '@components/core/Header.astro';
|
||||
import HintBar from '@components/core/HintBar.astro';
|
||||
import Footer from '@components/core/Footer.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pageId: PageId;
|
||||
title: string;
|
||||
description: string;
|
||||
ogType?: 'website' | 'profile' | 'article';
|
||||
jsonLdOpts?: {
|
||||
projectName?: string;
|
||||
projectType?: 'SoftwareApplication' | 'SoftwareSourceCode';
|
||||
};
|
||||
mainClass?: string;
|
||||
}
|
||||
const { locale, pageId, title, description, ogType, jsonLdOpts, mainClass } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={HTML_LANG[locale]} data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<ThemeScript />
|
||||
<Seo
|
||||
locale={locale}
|
||||
pageId={pageId}
|
||||
title={title}
|
||||
description={description}
|
||||
ogType={ogType}
|
||||
jsonLdOpts={jsonLdOpts}
|
||||
/>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#0B0E14" />
|
||||
<slot name="head" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="scroll-sentinel" aria-hidden="true" class="pointer-events-none absolute top-0 h-24 w-px"></div>
|
||||
<SkipLink locale={locale} />
|
||||
<Header locale={locale} pageId={pageId} />
|
||||
{locale === 'en' && <HintBar locale={locale} pageId={pageId} />}
|
||||
<main id="main" class={mainClass}>
|
||||
<slot />
|
||||
</main>
|
||||
<Footer locale={locale} pageId={pageId} />
|
||||
|
||||
{/* Global progressive-enhancement modules (deferred, fail-silent). */}
|
||||
<script>
|
||||
import '@scripts/theme';
|
||||
import '@scripts/nav';
|
||||
import '@scripts/observer';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,13 +5,13 @@
|
||||
*/
|
||||
|
||||
import * as S from './schema';
|
||||
import { profile as profileData } from '@/content/profile';
|
||||
import { skills as skillsData } from '@/content/skills';
|
||||
import { experience as experienceData } from '@/content/experience';
|
||||
import { meta as metaData } from '@/content/meta';
|
||||
import { jobtrack } from '@/content/projects/jobtrack';
|
||||
import { inboxintel } from '@/content/projects/inboxintel';
|
||||
import { homelab } from '@/content/projects/homelab';
|
||||
import { profile as profileData } from '@/data/profile';
|
||||
import { skills as skillsData } from '@/data/skills';
|
||||
import { experience as experienceData } from '@/data/experience';
|
||||
import { meta as metaData } from '@/data/meta';
|
||||
import { jobtrack } from '@/data/projects/jobtrack';
|
||||
import { inboxintel } from '@/data/projects/inboxintel';
|
||||
import { homelab } from '@/data/projects/homelab';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { pathFor, type PageId, type ProjectPageId } from '@i18n/slugMap';
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
SEO helpers (SEO_SPEC). hreflang alternates and the sitemap derive from the slug
|
||||
map (ARCHITECTURE A5), so they can never drift from the routes.
|
||||
*/
|
||||
|
||||
import { LOCALES, HREFLANG, HTML_LANG, DEFAULT_LOCALE, type Locale } from '@i18n/locales';
|
||||
import { pathFor, type PageId } from '@i18n/slugMap';
|
||||
import { getProfile } from '@lib/content';
|
||||
|
||||
export function absUrl(path: string, origin: string): string {
|
||||
return new URL(path, origin).href;
|
||||
}
|
||||
|
||||
/** hreflang alternates for a page: en, nb, and x-default → en (SEO_SPEC §2). */
|
||||
export function alternates(pageId: PageId, origin: string) {
|
||||
const list = LOCALES.map((l) => ({
|
||||
hreflang: HREFLANG[l],
|
||||
href: absUrl(pathFor(pageId, l), origin),
|
||||
}));
|
||||
list.push({ hreflang: 'x-default', href: absUrl(pathFor(pageId, DEFAULT_LOCALE), origin) });
|
||||
return list;
|
||||
}
|
||||
|
||||
export function ogImagePath(pageId: PageId, locale: Locale): string {
|
||||
return `/og/${pageId}-${locale}.png`;
|
||||
}
|
||||
|
||||
const JOB_TITLE: Record<Locale, string> = { en: 'Systems Developer', no: 'Systemutvikler' };
|
||||
|
||||
/** JSON-LD graph for a page (SEO_SPEC §3). */
|
||||
export function buildJsonLd(
|
||||
pageId: PageId,
|
||||
locale: Locale,
|
||||
origin: string,
|
||||
opts: { projectName?: string; projectType?: 'SoftwareApplication' | 'SoftwareSourceCode' } = {},
|
||||
): object[] {
|
||||
const p = getProfile(locale);
|
||||
const graph: object[] = [];
|
||||
|
||||
const person = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: p.name,
|
||||
jobTitle: JOB_TITLE[locale],
|
||||
url: absUrl(pathFor('home', locale), origin),
|
||||
email: `mailto:${p.links.email}`,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: 'Tønsberg',
|
||||
addressCountry: 'NO',
|
||||
},
|
||||
sameAs: [p.links.linkedin, p.links.gitea],
|
||||
knowsLanguage: ['en', 'nb'],
|
||||
knowsAbout: ['C#', '.NET', 'React', 'TypeScript', 'Python', 'SQL', 'Docker', 'Linux'],
|
||||
};
|
||||
|
||||
if (pageId === 'home') {
|
||||
graph.push(person);
|
||||
graph.push({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: p.name,
|
||||
url: absUrl(pathFor('home', locale), origin),
|
||||
inLanguage: HTML_LANG[locale],
|
||||
});
|
||||
} else if (opts.projectName) {
|
||||
graph.push({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': opts.projectType ?? 'CreativeWork',
|
||||
name: opts.projectName,
|
||||
author: person,
|
||||
inLanguage: HTML_LANG[locale],
|
||||
url: absUrl(pathFor(pageId, locale), origin),
|
||||
});
|
||||
} else {
|
||||
// Breadcrumb for other inner pages.
|
||||
graph.push({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: p.name,
|
||||
item: absUrl(pathFor('home', locale), origin),
|
||||
},
|
||||
{ '@type': 'ListItem', position: 2, item: absUrl(pathFor(pageId, locale), origin) },
|
||||
],
|
||||
});
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
@@ -1,23 +1,5 @@
|
||||
---
|
||||
import '@fontsource-variable/space-grotesk';
|
||||
import '@fontsource-variable/inter';
|
||||
import '@fontsource-variable/jetbrains-mono';
|
||||
import '@styles/global.css';
|
||||
import Homepage from '@components/home/Homepage.astro';
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en-GB" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Toolchain smoke test</title>
|
||||
</head>
|
||||
<body>
|
||||
<main class="mx-auto max-w-[--content-max] p-8">
|
||||
<p class="mono-label">Smoke test</p>
|
||||
<h1 class="text-display font-display">Connor Babbington</h1>
|
||||
<p class="text-body-lg text-ink-muted">Tokens, Tailwind and fonts are wired.</p>
|
||||
<div class="mt-6 inline-flex rounded-sm bg-accent px-4 py-2 text-accent-ink">accent button</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
<Homepage locale="en" />
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import Homepage from '@components/home/Homepage.astro';
|
||||
---
|
||||
|
||||
<Homepage locale="no" />
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
Screenshot lightbox (MICRO_INTERACTIONS). Loaded only on case-study pages. Builds
|
||||
its overlay lazily, traps focus, supports arrows / Esc / backdrop, restores focus.
|
||||
Only real images render trigger buttons (placeholders don't), so this stays inert
|
||||
until captures land.
|
||||
*/
|
||||
interface Shot {
|
||||
src: string;
|
||||
caption: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
const triggers = Array.from(document.querySelectorAll<HTMLButtonElement>('[data-lightbox]'));
|
||||
|
||||
if (triggers.length) {
|
||||
const shots: Shot[] = triggers.map((t) => ({
|
||||
src: t.dataset.lightboxSrc ?? '',
|
||||
caption: t.dataset.lightboxCaption ?? '',
|
||||
alt: t.getAttribute('aria-label') ?? '',
|
||||
}));
|
||||
|
||||
let lastFocus: HTMLElement | null = null;
|
||||
let index = 0;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className =
|
||||
'fixed inset-0 z-[70] hidden items-center justify-center bg-black/85 p-4 backdrop-blur-sm';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.innerHTML = `
|
||||
<button data-lb-close class="absolute right-4 top-4 h-10 w-10 rounded-sm border border-white/20 text-white" aria-label="Close">✕</button>
|
||||
<button data-lb-prev class="absolute left-4 h-10 w-10 rounded-sm border border-white/20 text-white" aria-label="Previous">‹</button>
|
||||
<figure class="max-h-full max-w-5xl">
|
||||
<img data-lb-img class="max-h-[80vh] w-full rounded-md object-contain" alt="" />
|
||||
<figcaption class="mt-3 text-center font-mono text-[13px] text-white/70">
|
||||
<span data-lb-caption></span> <span data-lb-counter class="ml-2 text-white/40"></span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<button data-lb-next class="absolute right-4 bottom-1/2 h-10 w-10 rounded-sm border border-white/20 text-white" aria-label="Next">›</button>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const img = overlay.querySelector<HTMLImageElement>('[data-lb-img]')!;
|
||||
const cap = overlay.querySelector<HTMLElement>('[data-lb-caption]')!;
|
||||
const counter = overlay.querySelector<HTMLElement>('[data-lb-counter]')!;
|
||||
const closeBtn = overlay.querySelector<HTMLButtonElement>('[data-lb-close]')!;
|
||||
|
||||
function render(): void {
|
||||
const shot = shots[index]!;
|
||||
img.src = shot.src;
|
||||
img.alt = shot.alt;
|
||||
cap.textContent = shot.caption;
|
||||
counter.textContent = `${index + 1}/${shots.length}`;
|
||||
}
|
||||
function open(i: number): void {
|
||||
index = i;
|
||||
lastFocus = document.activeElement as HTMLElement;
|
||||
render();
|
||||
overlay.classList.remove('hidden');
|
||||
overlay.classList.add('flex');
|
||||
closeBtn.focus();
|
||||
}
|
||||
function close(): void {
|
||||
overlay.classList.add('hidden');
|
||||
overlay.classList.remove('flex');
|
||||
lastFocus?.focus();
|
||||
}
|
||||
const step = (delta: number) => {
|
||||
index = (index + delta + shots.length) % shots.length;
|
||||
render();
|
||||
};
|
||||
|
||||
triggers.forEach((t, i) => t.addEventListener('click', () => open(i)));
|
||||
closeBtn.addEventListener('click', close);
|
||||
overlay.querySelector('[data-lb-prev]')!.addEventListener('click', () => step(-1));
|
||||
overlay.querySelector('[data-lb-next]')!.addEventListener('click', () => step(1));
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (overlay.classList.contains('hidden')) return;
|
||||
if (e.key === 'Escape') close();
|
||||
if (e.key === 'ArrowLeft') step(-1);
|
||||
if (e.key === 'ArrowRight') step(1);
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
closeBtn.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
Navigation behaviours (COMPONENT_ARCHITECTURE §3):
|
||||
- mobile overlay open/close with focus trap, scroll lock and Esc
|
||||
- header compression flag via an IntersectionObserver sentinel (no scroll listener)
|
||||
- native CV <details> menu enhancement (outside-click / Esc close)
|
||||
- first-visit language hint (ROUTING_SPEC §3): shown once, never auto-redirects
|
||||
*/
|
||||
|
||||
/* ---------- Mobile overlay ---------- */
|
||||
const toggle = document.getElementById('mobile-nav-toggle');
|
||||
const panel = document.getElementById('mobile-nav');
|
||||
const closeBtn = document.getElementById('mobile-nav-close');
|
||||
|
||||
function focusable(el: HTMLElement): HTMLElement[] {
|
||||
return Array.from(
|
||||
el.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'),
|
||||
).filter((n) => n.offsetParent !== null);
|
||||
}
|
||||
|
||||
function openNav(): void {
|
||||
if (!panel || !toggle) return;
|
||||
panel.dataset.open = '';
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
document.body.style.overflow = 'hidden';
|
||||
focusable(panel)[0]?.focus();
|
||||
}
|
||||
function closeNav(): void {
|
||||
if (!panel || !toggle) return;
|
||||
delete panel.dataset.open;
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
document.body.style.overflow = '';
|
||||
toggle.focus();
|
||||
}
|
||||
|
||||
toggle?.addEventListener('click', openNav);
|
||||
closeBtn?.addEventListener('click', closeNav);
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!panel || panel.dataset.open === undefined) return;
|
||||
if (e.key === 'Escape') closeNav();
|
||||
if (e.key === 'Tab') {
|
||||
const items = focusable(panel);
|
||||
if (items.length === 0) return;
|
||||
const first = items[0]!;
|
||||
const last = items[items.length - 1]!;
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- Header compression ---------- */
|
||||
const header = document.getElementById('site-header');
|
||||
const sentinel = document.getElementById('scroll-sentinel');
|
||||
if (header && sentinel && 'IntersectionObserver' in window) {
|
||||
new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry) return;
|
||||
if (entry.isIntersecting) delete header.dataset.scrolled;
|
||||
else header.dataset.scrolled = '';
|
||||
},
|
||||
{ threshold: 0 },
|
||||
).observe(sentinel);
|
||||
}
|
||||
|
||||
/* ---------- CV <details> menu: close on outside click / Esc ---------- */
|
||||
document.querySelectorAll<HTMLDetailsElement>('.cv-menu').forEach((menu) => {
|
||||
document.addEventListener('click', (e) => {
|
||||
if (menu.open && !menu.contains(e.target as Node)) menu.open = false;
|
||||
});
|
||||
menu.addEventListener('keydown', (e) => {
|
||||
if ((e as KeyboardEvent).key === 'Escape') menu.open = false;
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- Click-to-copy (global: contact band + contact page) ---------- */
|
||||
document.querySelectorAll<HTMLElement>('[data-copy]').forEach((el) => {
|
||||
el.addEventListener('click', async () => {
|
||||
const value = el.dataset.copy ?? '';
|
||||
const labelEl = el.querySelector<HTMLElement>('[data-copy-label]');
|
||||
const done = el.dataset.copiedLabel ?? 'Copied';
|
||||
const original = labelEl?.textContent ?? '';
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
if (labelEl) {
|
||||
labelEl.textContent = done;
|
||||
window.setTimeout(() => {
|
||||
labelEl.textContent = original;
|
||||
}, 1500);
|
||||
}
|
||||
} catch {
|
||||
/* clipboard blocked — the mailto link is still the fallback */
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- First-visit language hint ---------- */
|
||||
const hint = document.getElementById('lang-hint');
|
||||
if (hint) {
|
||||
const DISMISS_KEY = 'lang-hint-dismissed';
|
||||
let dismissed = false;
|
||||
try {
|
||||
dismissed = localStorage.getItem(DISMISS_KEY) === '1';
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const prefersNo = /^(nb|nn|no)\b/i.test(navigator.language || '');
|
||||
if (!dismissed && prefersNo) {
|
||||
hint.hidden = false;
|
||||
hint.querySelector('[data-hint-dismiss]')?.addEventListener('click', () => {
|
||||
hint.hidden = true;
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
Scroll-linked reveals + spy via a single shared IntersectionObserver
|
||||
(ANIMATION_SPEC §3: no scroll-handler layout reads). Content is never gated
|
||||
behind these — everything is visible if JS is absent.
|
||||
*/
|
||||
|
||||
/* ---------- Reveal-once (A2, A4, A8) ---------- */
|
||||
const revealTargets = document.querySelectorAll<HTMLElement>('[data-reveal]');
|
||||
if (revealTargets.length && 'IntersectionObserver' in window) {
|
||||
const revealObserver = new IntersectionObserver(
|
||||
(entries, obs) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('is-revealed');
|
||||
obs.unobserve(entry.target);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.2, rootMargin: '0px 0px -10% 0px' },
|
||||
);
|
||||
revealTargets.forEach((t) => revealObserver.observe(t));
|
||||
} else {
|
||||
// No observer support: show everything immediately.
|
||||
revealTargets.forEach((t) => t.classList.add('is-revealed'));
|
||||
}
|
||||
|
||||
/* ---------- Scroll-spy for same-page anchors (case-study MiniToc) ---------- */
|
||||
const spySections = document.querySelectorAll<HTMLElement>('[data-spy]');
|
||||
const spyLinks = new Map<string, HTMLElement>();
|
||||
document.querySelectorAll<HTMLElement>('[data-spy-link]').forEach((link) => {
|
||||
const id = link.dataset.spyLink;
|
||||
if (id) spyLinks.set(id, link);
|
||||
});
|
||||
|
||||
if (spySections.length && spyLinks.size && 'IntersectionObserver' in window) {
|
||||
const setActive = (id: string) => {
|
||||
spyLinks.forEach((link, key) => link.classList.toggle('is-active', key === id));
|
||||
};
|
||||
const spyObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting && entry.target.id) setActive(entry.target.id);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 },
|
||||
);
|
||||
spySections.forEach((s) => spyObserver.observe(s));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Theme toggle (A6). The no-flash init in ThemeScript already set data-theme before
|
||||
paint; this only wires the button, persists the choice, and keeps the aria-label
|
||||
describing the *next* action. Fail-silent if the button is absent.
|
||||
*/
|
||||
const html = document.documentElement;
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
|
||||
function labelFor(theme: string): string {
|
||||
if (!btn) return '';
|
||||
return theme === 'dark'
|
||||
? (btn.dataset.toLight ?? '')
|
||||
: (btn.dataset.toDark ?? '');
|
||||
}
|
||||
|
||||
if (btn) {
|
||||
btn.setAttribute('aria-label', labelFor(html.dataset.theme ?? 'dark'));
|
||||
btn.addEventListener('click', () => {
|
||||
const next = html.dataset.theme === 'light' ? 'dark' : 'light';
|
||||
html.dataset.theme = next;
|
||||
try {
|
||||
localStorage.setItem('theme', next);
|
||||
} catch {
|
||||
/* private mode — ignore */
|
||||
}
|
||||
btn.setAttribute('aria-label', labelFor(next));
|
||||
});
|
||||
}
|
||||
@@ -170,6 +170,28 @@
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Theme-toggle icon swap driven purely by the [data-theme] attribute. */
|
||||
.icon-sun {
|
||||
display: none;
|
||||
}
|
||||
:root[data-theme='light'] .icon-sun {
|
||||
display: block;
|
||||
}
|
||||
:root[data-theme='light'] .icon-moon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Native <details> CV menu: hide the default disclosure marker. */
|
||||
.cv-menu > summary {
|
||||
list-style: none;
|
||||
}
|
||||
.cv-menu > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.cv-menu > summary::marker {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
/* High-contrast / forced-colors: drop decorative texture (COLOUR_SYSTEM §5). */
|
||||
|
||||
Reference in New Issue
Block a user