Initial portfolio site: Astro + Tailwind MVP

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.
This commit is contained in:
2026-06-17 16:22:53 +10:00
commit 2d4b6ea097
38 changed files with 8232 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
---
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>