Files
com.gregkilmer.www/src/layouts/BaseLayout.astro
T
2026-08-06 17:29:26 -04:00

166 lines
6.7 KiB
Plaintext

---
/**
* BaseLayout.astro
* ---------------------------------------------------------------------------
* The shared shell for every page: <html>, <head> (fonts, meta, theme-init
* inline script), the fixed NavBar, the main content slot, and the Footer.
*
* Theming axis #1 (light/dark) is set on <html data-theme> by the inline
* theme-init script before paint (no FOUC). Theming axis #2 (section tone) is
* set on the <body data-surface> via the `surface` prop so each page can opt
* into neutral / professional / interest styling.
*
* Page transitions use Astro's <ClientRouter /> (View Transitions API).
*/
import "@/styles/global.css";
// Self-hosted fonts (woff2, variable). Avoids external requests for perf + privacy.
import "@fontsource-variable/inter";
import "@fontsource-variable/plus-jakarta-sans";
import "@fontsource-variable/jetbrains-mono";
import { ClientRouter } from "astro:transitions";
import NavBar from "@components/nav/NavBar.astro";
import StoreNav from "@components/store/StoreNav.astro";
import Footer from "@components/layout/Footer.astro";
import { themeInitScript } from "@/scripts/theme-init";
import { siteIdentity } from "@lib/site";
// Dev-only react-refresh preamble (see astro.config.mjs for why). Imported as a
// raw string so we can emit it as the FIRST module script in <head>, ahead of
// any React island, so $RefreshReg$/$RefreshSig$ exist before islands eval.
import { REACT_REFRESH_PREAMBLE } from "../../astro.config.mjs";
// Only emit the preamble in dev (import.meta.env.DEV). Production builds compile
// islands to jsx/jsxs and never touch the refresh runtime.
const isDev = import.meta.env.DEV;
type Surface = "neutral" | "professional" | "interest";
interface Props {
title?: string;
description?: string;
/** Which section tone to apply. Defaults to neutral (Home/About/Contact). */
surface?: Surface;
/** Override default OG image path. */
ogImage?: string;
/** When true, emit <meta name="robots" content="noindex, nofollow">. Use on
pages that shouldn't be indexed (e.g. /contact, to reduce scraping). */
noindex?: boolean;
/** When true, render the store-only nav (StoreNav) instead of the main NavBar.
Used by store pages, which intentionally share no links with the main site. */
storeNav?: boolean;
}
const {
title,
description = "Greg Kilmer is a software developer, maker, and tabletop gamemaster based in Philadelphia. This is where his professional and personal worlds meet.",
surface = "neutral",
ogImage,
noindex = false,
storeNav = false,
} = Astro.props;
// True on the Home page. Used to switch the NavBar's view-transition-name so
// the Home hero can morph into / out of the nav bar (expand/collapse effect).
const isHome = Astro.url.pathname === "/";
const fullTitle = title
? `${title} | ${siteIdentity.name}`
: `${siteIdentity.name} — Developer. Maker. Storyteller.`;
const canonical = new URL(
Astro.url.pathname,
Astro.site ?? "https://gregkilmer.com",
);
const ogImageUrl = ogImage
? new URL(ogImage, Astro.site ?? "https://gregkilmer.com").toString()
: new URL(
"/images/hero-portrait.jpg",
Astro.site ?? "https://gregkilmer.com",
).toString();
---
<!doctype html>
<!-- no-js guard: removed by reveal-init.ts once JS runs; while present, the
global.css `.no-js .reveal { opacity:1 }` rule keeps content visible even
if the reveal script fails. Theme model: data-theme on <html>, data-surface
on <body>. DARK MODE IS CURRENTLY FORCED — light mode is deferred (the
site is designed for dark first). theme-init.ts pins data-theme="dark" and
ignores any stored/OS preference until light mode is reintroduced. -->
<html lang="en" class="no-js" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content={Astro.generator} />
{/* Dev-only react-refresh preamble. MUST be the first module script so the
refresh globals exist before any React island module evaluates. Without
it, islands throw at eval time and never hydrate (see astro.config.mjs). */}
{isDev && (
<script type="module" set:html={REACT_REFRESH_PREAMBLE} />
)}
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
<meta name="theme-color" content="#0b1026" />
<!-- Open Graph / social cards -->
<meta property="og:type" content="website" />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:site_name" content={siteIdentity.name} />
{ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<!-- Theme boot: blocking inline script prevents flash of the wrong theme.
Dark mode is currently forced (light mode deferred), so this just pins
data-theme="dark" before paint. No data-astro-rerun needed since the
theme never changes across navigations. -->
<script is:inline set:html={themeInitScript} />
<!-- Reveal-on-scroll observer. Pairs with Reveal.astro / [data-reveal].
Loaded as a module so it self-initializes on load + on View Transitions
(it listens for astro:page-load internally). -->
<script>
import "@/scripts/reveal-init";
</script>
<!-- View Transitions flash-killer. The incoming page is snapshotted with its
`.reveal` elements still at opacity:0 (the observer hasn't fired yet),
which makes content vanish during the morph. This toggles a class on
<html> around the swap so global.css forces reveals visible mid-flight,
then lets the observer take over for scroll reveals on the new page. -->
<script is:inline>
document.addEventListener("astro:before-swap", () => {
document.documentElement.classList.add("vt-transitioning");
});
document.addEventListener("astro:after-swap", () => {
document.documentElement.classList.remove("vt-transitioning");
});
</script>
<ClientRouter />
</head>
<body data-surface={surface} data-page={isHome ? "home" : "other"}>
<a
href="#main"
class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:rounded-lg focus:bg-(--color-amber) focus:px-4 focus:py-2 focus:text-(--color-ink)"
>
Skip to content
</a>
{storeNav ? <StoreNav /> : <NavBar isHome={isHome} />}
<main id="main">
<slot />
</main>
<Footer />
</body>
</html>