2d4b6ea097
Outcome-led hero, about, grouped skills, experience summary, featured projects + /projects index, static contact, SEO/OG, dark/light theme. Dockerfile + nginx config + build script for homelab deploy.
101 lines
3.2 KiB
Plaintext
101 lines
3.2 KiB
Plaintext
---
|
|
import "../styles/global.css";
|
|
import Nav from "../components/Nav.astro";
|
|
import Footer from "../components/Footer.astro";
|
|
import { site } from "../data/site";
|
|
|
|
interface Props {
|
|
title?: string;
|
|
description?: string;
|
|
/** Path to the page, e.g. "/projects" — used for canonical + OG url. */
|
|
path?: string;
|
|
}
|
|
|
|
const {
|
|
title,
|
|
description = site.positioning,
|
|
path = "/",
|
|
} = Astro.props;
|
|
|
|
const pageTitle = title ? `${title} — ${site.name}` : `${site.name} · ${site.role}`;
|
|
const canonical = new URL(path, site.url).href;
|
|
const ogImage = new URL(site.ogImage, site.url).href;
|
|
---
|
|
|
|
<!doctype html>
|
|
<html lang={site.locale}>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
<link rel="canonical" href={canonical} />
|
|
|
|
<title>{pageTitle}</title>
|
|
<meta name="description" content={description} />
|
|
<meta name="author" content={site.name} />
|
|
|
|
<!-- Open Graph -->
|
|
<meta property="og:type" content="website" />
|
|
<meta property="og:title" content={pageTitle} />
|
|
<meta property="og:description" content={description} />
|
|
<meta property="og:url" content={canonical} />
|
|
<meta property="og:image" content={ogImage} />
|
|
<meta property="og:site_name" content={site.name} />
|
|
|
|
<!-- Twitter -->
|
|
<meta name="twitter:card" content="summary_large_image" />
|
|
<meta name="twitter:title" content={pageTitle} />
|
|
<meta name="twitter:description" content={description} />
|
|
<meta name="twitter:image" content={ogImage} />
|
|
|
|
<meta name="theme-color" content="#090c14" />
|
|
|
|
<!-- Pre-paint theme bootstrap: avoids a flash of the wrong theme.
|
|
The ONE inline script on the site; hashed by Astro CSP in M4. -->
|
|
<script is:inline>
|
|
(function () {
|
|
try {
|
|
var stored = localStorage.getItem("theme");
|
|
var theme =
|
|
stored ||
|
|
(window.matchMedia("(prefers-color-scheme: light)").matches
|
|
? "light"
|
|
: "dark");
|
|
document.documentElement.dataset.theme = theme;
|
|
} catch (e) {
|
|
document.documentElement.dataset.theme = "dark";
|
|
}
|
|
})();
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<a class="skip-link" href="#main">Skip to content</a>
|
|
<Nav />
|
|
<main id="main">
|
|
<slot />
|
|
</main>
|
|
<Footer />
|
|
|
|
<!-- Reveal-on-scroll: progressive enhancement, motion-aware. -->
|
|
<script>
|
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
const targets = document.querySelectorAll<HTMLElement>("[data-reveal]");
|
|
if (!reduce && "IntersectionObserver" in window) {
|
|
targets.forEach((el) => el.classList.add("reveal"));
|
|
const io = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting) {
|
|
entry.target.classList.add("is-visible");
|
|
io.unobserve(entry.target);
|
|
}
|
|
}
|
|
},
|
|
{ rootMargin: "0px 0px -10% 0px", threshold: 0.1 },
|
|
);
|
|
targets.forEach((el) => io.observe(el));
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|