Layout & Page Shell
Every page is BaseLayout wrapping a single default slot, with the header and footer as named slots. The layout owns <html>, the <head> through BaseHead, and a <main>; the page owns its title, description, SEO extras and the order of its sections.
<BaseLayout title="…" description="…" image={heroImage} noindex schema={[…]} article={{…}}>
<SiteHeader slot="header" />
<Hero />
<Deals />
<SiteFooter slot="footer" />
</BaseLayout>
BaseLayout’s props
title and description are required — there is no fallback, because a page without either is a mistake you want to hear about at build time. image is an ImageMetadata for the OG tag, defaulting to siteData.defaultImage. noindex adds the robots meta. schema and article are the SEO pass-throughs, typed by SeoProps in src/js/schema.ts so BaseLayout and BaseHead cannot drift apart.
The layout is deliberately chrome-free: it renders no navigation of its own. That is what lets the 404 be a full-height centred panel and the dev catalog be a bare page — but it also means a route that forgets the slots ships without navigation, which fourteen pages currently do. Routing names them and the fix.
<body> carries min-h-[100lvh] and nothing else. Page background and text colour come from html, so a section can paint a full-bleed band without fighting a body background.
What BaseHead emits
One hundred lines, no SEO dependency: charset and viewport, the font preload, the favicons, the sitemap and RSS alternate links, then <title>, description, canonical, the optional robots meta, the full Open Graph set with real image dimensions, article:* when the page passes article, the Twitter card, the JSON-LD graph, and — last — <ClientRouter /> when useViewTransitions is on. SEO goes through the tags themselves.
Two details in there are easy to get wrong elsewhere. og:locale needs language_TERRITORY, so siteLocale’s BCP-47 en-US is converted with a one-line replace. And og:image:width/height use the bundled image’s real dimensions when a page passes one, falling back to 1200×630 for the default — which is why public/og.jpg must actually be that size.
The eleven shared sections
src/components/Sections/Global/ holds every band that appears on more than one page:
SiteHeader and SiteFooter are the chrome. AlertBand is the cyan CTA band above the footer. HowItWorks is the four-step explainer the valuation page and the reviews hub both use. IndexColumns is the multi-column A–Z list behind the makes, cities and trades blocks. FaqSection is the accordion. SectionHead is the heading-plus-sub pair at the top of a band. NextRail is the “what happens next” sidebar beside the forms. LedgerCard is the figure card. BandWave is the decorative divider. IndexMark is the brand mark.
The test of whether something belongs here is simple: two pages render it with different content. HowItWorks earns its place because two pages pass it four different steps; a band used once stays in its page’s own folder.
The header
SiteHeader is two rows: a full-bleed navy strip 40px tall carrying the index-status line and the Blog link, then a white primary nav row with the wordmark, seven nav links, and the Sign in / Saved buttons. On small screens the nav collapses into a Sheet — a native <dialog> pinned to the edge, with the shared delegated controller as its only JavaScript.
Its content is entirely navData plus siteData.name, and it is built from five primitives: Nav/NavLink, Button, Badge, Sheet and Icon. Nothing in it is bespoke markup that a primitive already covers, which is the pattern worth copying when you add a band.
The load-in is time-based rather than scroll-based on purpose: the strip slides from the top, the wordmark blur-fades, the nav links stagger up. Above-the-fold content cannot use a scroll timeline, because there is no scroll yet — so it uses the catalog’s time-based utilities through the rise/stagger helpers in ui/reveal/loadIn.ts. All of it obeys useAnimations and the global reduced-motion guard.
View transitions
useViewTransitions: true mounts <ClientRouter fallback="none" />, so navigation is a swap rather than a reload. Two consequences are load-bearing.
First, vite.build.assetsInlineLimit: 0 in astro.config.mjs is not a size preference — inlined short scripts break under the router. Leave it at zero.
Second, any script that binds to elements must survive a swap. The pattern in this codebase is either a delegated listener bound once to the document (the dialog and popover controllers), or an idempotent init() re-run on astro:page-load (the browse filter, the saved dashboard). The shared _client.ts helper onReady wraps the second form. A script that binds directly on load and never re-runs will work on a full page load and silently stop working after the first in-site navigation, which is the hardest version of this bug to notice.
The dev catalog’s stylesheet cost
src/components/Sections/UiCatalog/ and src/pages/examples/ emit no pages in production, but Tailwind scans source. Their demo classes therefore sit in the stylesheet that every page loads: deleting both takes the shared BaseLayout CSS from 113,715 to 95,172 bytes and the built @keyframes count from 92 to 23.
Keep the catalog while you are still choosing primitives — it is the only place all 37 render together — and delete it before launch.