Component library
Back to templates

Page template

Local service homepage

A light, full homepage for a local service business: sticky mega-menu header, framed hero, logo row, services grid, process, testimonial, FAQ, contact form and footer.

homepagelocal businesslightlead gen

Open preview in a new tab

Use this template

Two ways to get Local service homepage into one of your own projects. The first two carry the template's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole page: what to do, where it goes, what must be replaced, and all 16 files appended underneath — the page file plus every component it uses — each with the path it belongs at. Paste it into Claude Code inside the destination project.

Read it first — the files are added underneath when you copy
Build a page in this Astro project from the "Local service homepage" page template in my Astro Component Library.

Library page, for reference only:
https://astro.baysixmedia.com/templates/local-service-homepage/

Route:
[ENTER THE ROUTE THIS PAGE SHOULD BECOME, e.g. / or /services/drain-cleaning]

Business and content:
[ENTER THE BUSINESS NAME, SERVICES, SERVICE AREA, PHONE, AND ANY APPROVED COPY — OR POINT AT THE FILE THAT HOLDS THEM]

Every file you need is appended below, each fenced with the path it belongs at. The page file is src/pages/local-service-homepage.astro; every other file is a component it imports. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

1. Inspect this project first: its shared layout, global stylesheet, existing header and footer, and whether the target route already exists. Report what you found before writing anything.
2. Write the component files to their paths. If a file with the same name already exists and differs, stop and ask rather than overwriting it.
3. Put the page at the route above, not at src/pages/local-service-homepage.astro unless that is the route. If the route already has a page, replace it only after confirming with me. Update the page's relative imports to match wherever it ends up.
4. If this project has a shared layout, render the page through it instead of the template's own <html> shell, and keep the layout's head, analytics and scripts. Keep exactly one header, one footer and one <main>, and keep a working skip link.
5. Replace ALL demo content in the page's props — every name, heading, paragraph, testimonial, logo, link, image and form destination — with this business's real content. Do not ship demo copy, invented reviews, invented statistics or placeholder links. Where real content is missing, leave a clearly marked TODO rather than inventing it.
6. Map the header and footer links to this project's real routes. Every href must resolve.
7. Adapt colours and typography to the project through documented props where possible. Preserve each component's layout, responsive behaviour, accessibility, interactions and reduced-motion behaviour.

Test the page at 1440, 1280, 1024, 834, 390 and 320px: no horizontal overflow, keyboard operation throughout, and nothing hidden under the header. Run the project's check and build commands. Report the files written, the route, what content is still TODO, and anything you could not verify. Do not commit or deploy.

Install kit

The 16 files and nothing else: the page file and every component, each fenced with its path, followed by each component's verification status. For placing it yourself.

Read it first
Page template kit: "Local service homepage"
Library page: https://astro.baysixmedia.com/templates/local-service-homepage/
Page file: src/pages/local-service-homepage.astro
Files: 16 required, 1 supporting

Write each block below to the path named in its header, relative to the project root. The page file imports every component by relative path, so keep the destinations as given or update its imports to match. A block marked (required) is one the page does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.

===== FILE (required): src/pages/local-service-homepage.astro =====
---
/**
 * Local service homepage — a page template from the Astro Component Library.
 * https://astro.baysixmedia.com/templates/local-service-homepage/
 *
 * Every section is a library component with its demo content passed in as
 * props. Replace the props with this site's own content, and move the page
 * inside this project's own layout if it has one.
 */
import StickyMegaMenuHeader from "../components/StickyMegaMenuHeader.astro";
import FramedHero from "../components/FramedHero.astro";
import StaggeredLogoCloud from "../components/StaggeredLogoCloud.astro";
import IconFeatureGrid from "../components/IconFeatureGrid.astro";
import ProcessFlowGrid from "../components/ProcessFlowGrid.astro";
import TestimonialRoster from "../components/TestimonialRoster.astro";
import FaqAccordion from "../components/FaqAccordion.astro";
import ContactSplitForm from "../components/ContactSplitForm.astro";
import PanelFooter from "../components/PanelFooter.astro";

const stickyMegaMenuHeaderProps = {
	"brandName": "Kettlebridge",
	"items": [
		{
			"label": "Platform",
			"groups": [
				{
					"heading": "Who it is for",
					"links": [
						{
							"label": "Product teams",
							"href": "/for/product",
							"description": "Ship the thing you sketched."
						},
						{
							"label": "Designers",
							"href": "/for/design",
							"description": "Your layouts, built properly."
						},
						{
							"label": "Founders",
							"href": "/for/founders",
							"description": "A demo before the deck."
						},
						{
							"label": "Operations",
							"href": "/for/ops",
							"description": "Tools shaped to your process."
						}
					]
				},
				{
					"heading": "What it does",
					"links": [
						{
							"label": "Drafting",
							"href": "/drafting",
							"description": "Start from a rough idea."
						},
						{
							"label": "Review",
							"href": "/review",
							"description": "Comments where the work is."
						},
						{
							"label": "Handover",
							"href": "/handover",
							"description": "Everything the next person needs."
						},
						{
							"label": "Archive",
							"href": "/archive",
							"description": "Findable a year later."
						}
					]
				}
			]
		},
		{
			"label": "Resources",
			"groups": [
				{
					"heading": "Learn",
					"links": [
						{
							"label": "Guides",
							"href": "/guides",
							"description": "Short, practical walkthroughs."
						},
						{
							"label": "Changelog",
							"href": "/changelog",
							"description": "What moved this month."
						},
						{
							"label": "Field notes",
							"href": "/notes",
							"description": "Writing from the team."
						}
					]
				}
			]
		},
		{
			"label": "Community",
			"href": "/community"
		},
		{
			"label": "Pricing",
			"href": "/pricing"
		},
		{
			"label": "Security",
			"href": "/security"
		}
	],
	"loginLabel": "Log in",
	"loginHref": "/login",
	"ctaLabel": "Get started",
	"ctaHref": "/signup",
	"currentPath": "/pricing"
};

const framedHeroProps = {
	"brand": "Northbeam",
	"heading": "Interfaces built\nto outlast the launch.",
	"subtitle": "We design and build the parts people touch, then hand over a system your team can keep changing without asking us first.",
	"kicker": "Studio practice · Systems over screens",
	"actions": [
		{
			"label": "Start a project",
			"href": "mailto:studio@northbeam.example",
			"variant": "solid"
		},
		{
			"label": "See the work",
			"variant": "outline"
		}
	],
	"image": "/framed-hero/backdrop-1920.webp",
	"imageSrcset": "/framed-hero/backdrop-1440.webp 1440w, /framed-hero/backdrop-1920.webp 1920w",
	"imageSizes": "100vw",
	"imageAlt": ""
};

const staggeredLogoCloudProps = {
	"label": "Trusted by people from",
	"groups": [
		{
			"id": "set-a",
			"logos": [
				{
					"id": "northwind",
					"name": "Northwind",
					"wordmark": "bold"
				},
				{
					"id": "kestrel",
					"name": "Kestrel",
					"wordmark": "dot"
				},
				{
					"id": "lumen",
					"name": "Lumen",
					"wordmark": "wide"
				},
				{
					"id": "arbor",
					"name": "Arbor",
					"wordmark": "plain"
				},
				{
					"id": "vantage",
					"name": "Vantage",
					"wordmark": "slash"
				},
				{
					"id": "halcyon",
					"name": "Halcyon",
					"wordmark": "box"
				},
				{
					"id": "meridian",
					"name": "Meridian",
					"wordmark": "plain"
				},
				{
					"id": "foundry",
					"name": "Foundry",
					"wordmark": "bold"
				}
			]
		},
		{
			"id": "set-b",
			"logos": [
				{
					"id": "bluepeak",
					"name": "Bluepeak",
					"wordmark": "plain"
				},
				{
					"id": "corvid",
					"name": "Corvid",
					"wordmark": "box"
				},
				{
					"id": "solstice",
					"name": "Solstice",
					"wordmark": "wide"
				},
				{
					"id": "ironwood",
					"name": "Ironwood",
					"wordmark": "bold"
				},
				{
					"id": "pelagic",
					"name": "Pelagic",
					"wordmark": "dot"
				},
				{
					"id": "vertex",
					"name": "Vertex",
					"wordmark": "slash"
				},
				{
					"id": "quarry",
					"name": "Quarry",
					"wordmark": "plain"
				},
				{
					"id": "lanternhouse",
					"name": "Lanternhouse",
					"wordmark": "plain"
				}
			]
		},
		{
			"id": "set-c",
			"logos": [
				{
					"id": "aurelia",
					"name": "Aurelia",
					"wordmark": "wide"
				},
				{
					"id": "tidewater",
					"name": "Tidewater",
					"wordmark": "plain"
				},
				{
					"id": "glasshouse",
					"name": "Glasshouse",
					"wordmark": "box"
				},
				{
					"id": "beacon",
					"name": "Beacon",
					"wordmark": "dot"
				},
				{
					"id": "cobalt",
					"name": "Cobalt",
					"wordmark": "bold"
				},
				{
					"id": "wayfarer",
					"name": "Wayfarer",
					"wordmark": "plain"
				},
				{
					"id": "orchard",
					"name": "Orchard",
					"wordmark": "slash"
				},
				{
					"id": "summit",
					"name": "Summit",
					"wordmark": "bold"
				}
			]
		}
	]
};

const iconFeatureGridProps = {
	"heading": "Built by a team that got tired of patching around the same gaps",
	"intro": "Every part of this was something we needed and could not find, so we wrote it down, argued about it, and shipped it as a default rather than an add-on.",
	"items": [
		{
			"icon": "orbit",
			"title": "Sandbox mode",
			"body": "Point the client at a throwaway workspace and let it fail loudly. Nothing you do in there reaches a real record."
		},
		{
			"icon": "pulse",
			"title": "Callbacks on every event",
			"body": "We post to your endpoint the moment something moves — queued, started, finished, abandoned — with the payload that caused it."
		},
		{
			"icon": "layers",
			"title": "Readable request logs",
			"body": "Every call, its response and the time it took, kept in order and searchable, so a bug report is a link rather than a story."
		},
		{
			"icon": "exchange",
			"title": "Retries you can forget about",
			"body": "A failed call backs off and comes round again on its own. You find out it happened from the log, not from a customer."
		},
		{
			"icon": "target",
			"title": "Delivery you can measure",
			"body": "Opens, clicks and bounces land on the same timeline as the send, so a drop in one is visible next to whatever caused it."
		},
		{
			"icon": "brackets",
			"title": "Bring your own markup",
			"body": "Markdown, a template language or hand-written HTML — whichever you hand us comes out the other side intact."
		},
		{
			"icon": "frame",
			"title": "Preview before you send",
			"body": "Render a draft against a set of common clients and read it the way the recipient will, including the ones that ignore half your CSS."
		},
		{
			"icon": "gauge",
			"title": "Your domain, your reputation",
			"body": "Verify a sending domain once and everything leaves under your own name instead of a shared subdomain nobody trusts."
		}
	]
};

const processFlowGridProps = {
	"label": "How the work runs",
	"steps": [
		{
			"title": "Frame",
			"body": "We agree what the work has to achieve, and what it does not, before anything is drawn."
		},
		{
			"title": "Shape",
			"body": "Structure, type and colour settle into a system rather than a set of separate screens."
		},
		{
			"title": "Build",
			"body": "It ships as components, each reviewed against the thing it replaces before the next one starts."
		},
		{
			"title": "Tend",
			"body": "Measured once it is live, then adjusted. A site is not finished, it is kept."
		}
	]
};

const testimonialRosterProps = {
	"headline": "Ask the people who run it every day.",
	"items": [
		{
			"name": "Ivy Sandoval",
			"role": "Head of Operations",
			"company": "Lantern Row",
			"quote": "We stopped keeping the schedule in three places and started keeping it in one. The arguing about which version was right went with it.",
			"image": "/testimonial-roster/portrait-1.svg",
			"badge": "Customer story",
			"rating": 5,
			"stat": {
				"value": "9h",
				"label": "Given back to the team each week"
			}
		},
		{
			"name": "Marcus Bell",
			"role": "Engineering Manager",
			"company": "Fenwick & Co",
			"quote": "The part I did not expect was how much quieter it got. Fewer people asking where something stands, because they can already see it.",
			"image": "/testimonial-roster/portrait-2.svg",
			"badge": "Customer story",
			"rating": 5,
			"stat": {
				"value": "2×",
				"label": "More shipped in the same month"
			}
		},
		{
			"name": "Priya Raman",
			"role": "Design Director",
			"company": "Northfield Studio",
			"quote": "Handing work between teams used to cost us a day each time. Now the context travels with the work and the day stays ours.",
			"image": "/testimonial-roster/portrait-3.svg",
			"badge": "Customer story",
			"rating": 5,
			"stat": {
				"value": "40%",
				"label": "Fewer handoffs between teams"
			}
		},
		{
			"name": "Theo Lindqvist",
			"role": "Founder",
			"company": "Meadowlark",
			"quote": "I wanted something the whole company could learn in an afternoon. Nobody has asked me how it works since the first week.",
			"image": "/testimonial-roster/portrait-4.svg",
			"badge": "Customer story",
			"rating": 5,
			"stat": {
				"value": "1 day",
				"label": "From first draft to sign-off"
			}
		}
	]
};

const faqAccordionProps = {
	"items": [
		{
			"question": "How do we begin?",
			"answer": [
				"Tell us roughly what you are trying to do and we will come back with a shape for it, or ",
				{
					"text": "send a brief",
					"href": "#brief"
				},
				" if you already have one. There is no charge for the first conversation."
			]
		},
		{
			"question": "How long does a project take?",
			"answer": "Most run six to ten weeks from first call to launch. Smaller pieces of work are quicker, and we will say so rather than stretch them to fill a quarter."
		},
		{
			"question": "Do you work with our existing team?",
			"answer": "Usually, yes. We are happiest alongside the people who will keep the thing running afterwards, and we write down the reasoning so it survives us leaving."
		},
		{
			"question": "What does it cost?",
			"answer": [
				"A fixed price for a defined scope, agreed before anything starts. Our ",
				{
					"text": "rates and terms",
					"href": "#terms"
				},
				" are published, so you can work out roughly where you land before you ask."
			]
		}
	],
	"heading": "Questions before you start?",
	"lead": "A few honest answers about scope, timing and what working together looks like.",
	"moreHref": "#all-questions",
	"moreLabel": "Read every question"
};

const contactSplitFormProps = {
	"heading": "Let's build something worth visiting.",
	"services": [
		"Website design",
		"Content and copy",
		"Brand identity",
		"Search and local listings",
		"Ongoing care",
		"Something else"
	],
	"email": "studio@example.com"
};

const panelFooterProps = {
	"brand": "Meridian Works",
	"description": "We design and build interfaces that stay maintainable long after launch.",
	"social": [
		{
			"label": "Website",
			"path": "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.8 5.5 3.8 9s-1.3 6.6-3.8 9m0-18C9.5 5.4 8.2 8.5 8.2 12s1.3 6.6 3.8 9M3.5 9h17m-17 6h17"
		},
		{
			"label": "Discussion",
			"path": "M20 12a7.5 7.5 0 0 1-10.9 6.7L4 20l1.4-4.3A7.5 7.5 0 1 1 20 12Z"
		},
		{
			"label": "Newsletter",
			"path": "M21 4 3 11l7 3 3 7 8-17Zm0 0-11 10"
		}
	],
	"groups": [
		{
			"title": "Explore",
			"links": [
				{
					"label": "Home"
				},
				{
					"label": "Work"
				},
				{
					"label": "Studio"
				},
				{
					"label": "Journal"
				},
				{
					"label": "Contact"
				}
			]
		},
		{
			"title": "Services",
			"links": [
				{
					"label": "Product design"
				},
				{
					"label": "Design systems"
				},
				{
					"label": "Front-end build"
				},
				{
					"label": "Accessibility audits"
				}
			]
		}
	],
	"contact": {
		"title": "Studio & contact",
		"legalName": "MERIDIAN WORKS LLC",
		"address": "Suite 200, 1400 Example Avenue, Portland, OR 97209",
		"phone": "+1 (555) 0142",
		"email": "studio@meridianworks.example"
	},
	"cta": {
		"label": "Start a project",
		"href": "mailto:studio@meridianworks.example"
	},
	"copyright": "© 2026 Meridian Works. All rights reserved.",
	"note": "Built to be maintained, not just launched.",
	"legal": [
		{
			"label": "Privacy"
		},
		{
			"label": "Terms"
		}
	]
};
---

<html lang="en">
	<head>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1" />
		<title>[Business name] — [Primary service] in [City]</title>
		<meta name="description" content="[One-sentence description of the business, its service area and its offer.]" />
		<style is:global>
			*,
			*::before,
			*::after {
				box-sizing: border-box;
			}

			body {
				margin: 0;
				background: #ffffff;
				font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
				color: #111;
			}

			.skip-link {
				position: absolute;
				left: 1rem;
				top: -100px;
				z-index: 1000;
				padding: 0.6rem 1rem;
				background: #111;
				color: #fff;
				border-radius: 6px;
			}

			.skip-link:focus {
				top: 1rem;
			}
		</style>
	</head>
	<body>
		<a class="skip-link" href="#main">Skip to content</a>

		<!-- Sticky mega menu header -->
		<StickyMegaMenuHeader {...stickyMegaMenuHeaderProps} />

		<main id="main">
			<!-- Framed hero -->
			<div class="band" style="background:#eef0f2">
				<FramedHero {...framedHeroProps} />
			</div>

			<!-- Staggered logo cloud -->
			<div class="band" style="background:#ffffff">
				<StaggeredLogoCloud {...staggeredLogoCloudProps} />
			</div>

			<!-- Icon feature grid -->
			<div class="band" style="background:#ffffff">
				<IconFeatureGrid {...iconFeatureGridProps} />
			</div>

			<!-- Process flow grid -->
			<div class="band" style="background:#f8fafc;padding:clamp(24px, 6vw, 72px)">
				<ProcessFlowGrid {...processFlowGridProps} />
			</div>

			<!-- Testimonial roster -->
			<div class="band" style="background:#ffffff">
				<TestimonialRoster {...testimonialRosterProps} />
			</div>

			<!-- FAQ accordion -->
			<div class="band" style="background:#eee6e0">
				<FaqAccordion {...faqAccordionProps} />
			</div>

			<!-- Contact split form -->
			<div class="band" style="background:#eceef1">
				<ContactSplitForm {...contactSplitFormProps} />
			</div>
		</main>

		<!-- Panel footer -->
		<div class="band" style="background:#eef0f2">
			<PanelFooter {...panelFooterProps} />
		</div>
	</body>
</html>

===== END src/pages/local-service-homepage.astro =====

===== FILE (required): src/components/StickyMegaMenuHeader.astro =====
---
/**
 * Sticky mega menu header
 *
 * A marketing site header that sticks to the top of the viewport, turns from
 * transparent to frosted glass the moment the page scrolls, opens wide mega
 * panels from its nav items, and collapses to a full-screen sheet below the
 * desktop breakpoint.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no images.
 * Every colour and measurement is a `--mh-*` custom property on the root.
 */

export interface MegaLink {
	label: string;
	href: string;
	/** Second line under the label inside a mega panel. */
	description?: string;
}

export interface MegaGroup {
	/** Small heading above a column of links. `""` renders no heading. */
	heading?: string;
	links: MegaLink[];
}

export interface NavItem {
	label: string;
	/** A plain destination. Give this OR `groups`, not both. */
	href?: string;
	/** Turns the item into a mega-panel trigger. */
	groups?: MegaGroup[];
}

interface Props {
	/** Wordmark text beside the brand mark. `""` renders the mark alone. */
	brandName?: string;
	/** Where the brand links to. */
	brandHref?: string;
	/** Accessible name for the primary `<nav>`. */
	navLabel?: string;
	/** Accessible name for the small-screen menu button. */
	menuLabel?: string;
	/** The navigation items. */
	items?: NavItem[];
	/** Secondary action at the right. `""` hides it. */
	loginLabel?: string;
	loginHref?: string;
	/** Primary action at the right. `""` hides it. */
	ctaLabel?: string;
	ctaHref?: string;
	/** Marks the matching link `aria-current="page"`. */
	currentPath?: string;
	/** Header height. The reference holds this constant on scroll. */
	height?: string;
	/** Caps the header's inner row. */
	maxWidth?: string;
	/** Page ground the header sits on. Also the colour it fades in when scrolled. */
	background?: string;
	/** Mega panels and the mobile sheet. */
	surface?: string;
	/** Primary text. */
	ink?: string;
	/** Descriptions and group headings. */
	muted?: string;
	/** Hairlines on panels and the sheet. */
	border?: string;
	/** Primary button fill. */
	ctaBg?: string;
	/** Primary button text. */
	ctaInk?: string;
	/** Focus ring and the current-page marker. */
	accent?: string;
	/** Backdrop blur once scrolled. `0px` turns the glass off. */
	blur?: string;
	/** Stacking order. It floats over the page, so it has to win. */
	zIndex?: number;
	/** Added to the root element. */
	class?: string;
	/** Root element id. Also seeds the panel and sheet ids. */
	id?: string;
}

const {
	// No invented brand, navigation tree or actions: the site supplies its own.
	brandName = "",
	brandHref = "/",
	navLabel = "Primary",
	menuLabel = "Toggle navigation menu",
	items = [],
	loginLabel = "",
	loginHref,
	ctaLabel = "",
	ctaHref,
	currentPath,
	height = "64px",
	maxWidth = "1280px",
	background = "#fcfbf8",
	surface = "#ffffff",
	ink = "#1a1a1a",
	muted = "#6b6b6b",
	border = "#e4e2dc",
	ctaBg = "rgb(0 0 0 / 88%)",
	ctaInk = "#fbfbfb",
	accent = "#3b5bdb",
	blur = "24px",
	zIndex = 50,
	class: className,
	id,
} = Astro.props;

// One id prefix per instance, so several headers can share a page without
// their triggers and panels pointing at each other's elements.
const uid = id ?? `mh-${Math.random().toString(36).slice(2, 9)}`;
const sheetId = `${uid}-sheet`;

const isCurrent = (href?: string) => Boolean(href && currentPath && href === currentPath);

// Each block renders only when it has something in it. The burger and the
// sheet exist only when there is navigation or an action to put in them; with
// no brand either, there is no header to draw at all.
const hasLogin = Boolean(loginLabel && loginHref);
const hasCta = Boolean(ctaLabel && ctaHref);
const hasActions = hasLogin || hasCta;
const hasMenu = items.length > 0 || hasActions;
const hasContent = hasMenu || Boolean(brandName);

const style = [
	`--mh-h:${height}`,
	`--mh-max:${maxWidth}`,
	`--mh-bg:${background}`,
	`--mh-surface:${surface}`,
	`--mh-ink:${ink}`,
	`--mh-muted:${muted}`,
	`--mh-border:${border}`,
	`--mh-cta-bg:${ctaBg}`,
	`--mh-cta-ink:${ctaInk}`,
	`--mh-accent:${accent}`,
	`--mh-blur:${blur}`,
	`--mh-z:${zIndex}`,
].join(";");
---

{hasContent && (
<header class:list={["header", className]} id={id} style={style} data-header-root data-mh-root>
	{/*
	    Sticky and the scroll state both live here. In the reference they sit on
	    an ancestor two levels above the matched element; folding them in is what
	    makes this one portable file rather than a fragment that needs a wrapper.
	*/}
	<div class="bar" data-mh-bar>
		<div class="inner">
			<a class="brand" href={brandHref}>
				<svg class="mark" viewBox="0 0 28 28" aria-hidden="true" focusable="false">
					<rect x="3" y="3" width="22" height="22" rx="7"></rect>
					<path d="M10 18.5V9.5l8 9v-9"></path>
				</svg>
				{brandName && <span class="brand-name">{brandName}</span>}
				{!brandName && <span class="sr-only">Home</span>}
			</a>

			{items.length > 0 && (
			<nav class="desk" aria-label={navLabel}>
				<ul class="row">
					{
						items.map((item, i) => {
							const panelId = `${uid}-panel-${i}`;
							return item.groups && item.groups.length > 0 ? (
								<li class="item" data-mh-item>
									<button
										type="button"
										class="trigger"
										data-mh-trigger
										aria-expanded="false"
										aria-controls={panelId}
										aria-haspopup="true"
									>
										{item.label}
										<svg class="chev" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
											<path d="M4 6.5 8 10.5 12 6.5"></path>
										</svg>
									</button>

									<div class="panel" id={panelId} data-mh-panel>
										<div class="card">
											{item.groups.map((group) => (
												<div class="group">
													{group.heading && <p class="group-head">{group.heading}</p>}
													<ul class="group-list">
														{group.links.map((link) => (
															<li>
																<a
																	class="cell"
																	href={link.href}
																	aria-current={isCurrent(link.href) ? "page" : null}
																>
																	<span class="cell-title">{link.label}</span>
																	{link.description && (
																		<span class="cell-desc">{link.description}</span>
																	)}
																</a>
															</li>
														))}
													</ul>
												</div>
											))}
										</div>
									</div>
								</li>
							) : (
								<li class="item">
									<a
										class="link"
										href={item.href}
										aria-current={isCurrent(item.href) ? "page" : null}
									>
										{item.label}
									</a>
								</li>
							);
						})
					}
				</ul>
			</nav>
			)}

			{hasActions && (
			<div class="actions">
				{loginLabel && loginHref && <a class="ghost" href={loginHref}>{loginLabel}</a>}
				{ctaLabel && ctaHref && <a class="cta" href={ctaHref}>{ctaLabel}</a>}
			</div>
			)}

			{hasMenu && (
			<button
				type="button"
				class="burger"
				data-mh-burger
				aria-expanded="false"
				aria-controls={sheetId}
				aria-label={menuLabel}
			>
				<svg class="bars" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
					<line class="bar-a" x1="4" y1="9" x2="20" y2="9"></line>
					<line class="bar-b" x1="4" y1="15" x2="20" y2="15"></line>
				</svg>
			</button>
			)}
		</div>
	</div>

	{/*
	    The small-screen counterpart. In the reference this is a sibling element
	    in the same header slot, shown by the inverse of the same breakpoint, and
	    it carries the same primary navigation — which is why it belongs in the
	    same component rather than being treated as an unrelated control.
	*/}
	{hasMenu && (
	<div class="sheet" id={sheetId} data-mh-sheet>
		<nav class="sheet-nav" aria-label={navLabel}>
			{
				items.map((item) =>
					item.groups && item.groups.length > 0 ? (
						<details class="sheet-group" name={`${uid}-sheet-acc`}>
							<summary class="sheet-summary">
								{item.label}
								<svg class="chev" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
									<path d="M4 6.5 8 10.5 12 6.5"></path>
								</svg>
							</summary>
							<ul class="sheet-sub">
								{item.groups.flatMap((g) => g.links).map((link) => (
									<li>
										<a
											class="sheet-link is-sub"
											href={link.href}
											aria-current={isCurrent(link.href) ? "page" : null}
										>
											{link.label}
										</a>
									</li>
								))}
							</ul>
						</details>
					) : (
						<a
							class="sheet-link"
							href={item.href}
							aria-current={isCurrent(item.href) ? "page" : null}
						>
							{item.label}
						</a>
					),
				)
			}

			{hasActions && (
			<div class="sheet-actions">
				{loginLabel && loginHref && <a class="ghost wide" href={loginHref}>{loginLabel}</a>}
				{ctaLabel && ctaHref && <a class="cta wide" href={ctaHref}>{ctaLabel}</a>}
			</div>
			)}
		</nav>
	</div>
	)}
</header>
)}

<style>
	/*
	 * Sticky lives on the root, not on the bar inside it. A sticky element can
	 * only travel within its own parent's box — and this root is exactly as
	 * tall as the bar, so sticking the bar to the root would give it nowhere to
	 * go and it would scroll away with the page. On the root, the containing
	 * block is whatever the host puts it in, which is the whole page.
	 */
	.header {
		--mh-pad: 16px;

		position: sticky;
		top: 0;
		z-index: var(--mh-z);
		box-sizing: border-box;
		color: var(--mh-ink);
		font-family:
			system-ui,
			-apple-system,
			"Segoe UI",
			Roboto,
			sans-serif;
		font-size: 16px;
		line-height: 1.5;
	}

	/* Set rather than inherited: a destination site's global rules must not be
	   able to change the header's height or push a row onto a second line. */
	.header *,
	.header *::before,
	.header *::after {
		box-sizing: border-box;
	}

	.header ul {
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.header p {
		margin: 0;
	}

	.sr-only {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
	}

	/* --- the sticky bar ---------------------------------------------------- */

	.bar {
		height: var(--mh-h);
		background: transparent;
		/* Only these two animate in the reference; the blur arrives instantly. */
		transition:
			background-color 300ms cubic-bezier(0.4, 0, 0.2, 1),
			box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	/* Set from the first pixel of scroll, exactly as the reference flips it. */
	[data-mh-scrolled] .bar {
		background: color-mix(in srgb, var(--mh-bg) 75%, transparent);
		-webkit-backdrop-filter: blur(var(--mh-blur));
		backdrop-filter: blur(var(--mh-blur));
		box-shadow: 0 1px 2px rgb(0 0 0 / 4%), 0 8px 24px -12px rgb(0 0 0 / 12%);
	}

	.inner {
		display: flex;
		align-items: center;
		gap: 16px;
		height: 100%;
		max-width: var(--mh-max);
		margin-inline: auto;
		padding-inline: var(--mh-pad);
	}

	/* --- brand -------------------------------------------------------------- */

	/*
	 * Below the desktop breakpoint the brand may shrink and the name wraps (two
	 * short lines fit the 64px bar), so a long business name never pushes the
	 * menu button off-screen. Desktop keeps the single line (see 1024px).
	 */
	.brand {
		display: inline-flex;
		flex: 0 1 auto;
		min-width: 0;
		align-items: center;
		gap: 8px;
		min-height: 44px;
		color: var(--mh-ink);
		text-decoration: none;
	}

	.mark {
		flex: none;
		width: 28px;
		height: 28px;
		fill: none;
		stroke: currentColor;
		stroke-width: 1.8;
		stroke-linecap: round;
		stroke-linejoin: round;
	}

	.brand-name {
		font-size: 1rem;
		font-weight: 600;
		letter-spacing: -0.01em;
		overflow-wrap: anywhere;
	}

	/* --- desktop nav --------------------------------------------------------- */

	.desk {
		display: none;
	}

	.row {
		display: flex;
		align-items: center;
		gap: 2px;
	}

	.item {
		position: relative;
		display: flex;
		align-items: center;
	}

	.link,
	.trigger {
		display: inline-flex;
		align-items: center;
		gap: 2px;
		min-height: 44px;
		margin: 0;
		padding: 4px 8px;
		border: 0;
		border-radius: 8px;
		background: none;
		color: var(--mh-ink);
		font: inherit;
		font-size: 0.9375rem;
		text-decoration: none;
		white-space: nowrap;
		cursor: pointer;
		transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	.link:hover,
	.trigger:hover,
	[data-mh-open] > .trigger {
		background: color-mix(in srgb, var(--mh-ink) 6%, transparent);
	}

	.link[aria-current="page"] {
		color: var(--mh-accent);
		font-weight: 500;
	}

	.chev {
		width: 14px;
		height: 14px;
		fill: none;
		stroke: currentColor;
		stroke-width: 1.6;
		stroke-linecap: round;
		stroke-linejoin: round;
		transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	[data-mh-open] > .trigger .chev {
		transform: rotate(180deg);
	}

	/* --- mega panel ----------------------------------------------------------- */

	/*
	 * Hidden by default and revealed on hover or focus-within, so the panels
	 * work with no JavaScript at all. Once the script runs it takes over with
	 * `data-mh-open`, which is what makes Escape and outside-click possible.
	 */
	/*
	 * `top: 100%` with the visual offset supplied by padding, not by a gap.
	 * A real gap between the trigger and the panel is a dead zone: the pointer
	 * crosses it, leaves the item, and the panel closes underneath. Padding
	 * keeps the panel's hit box flush against the trigger, so travelling down
	 * into it never leaves the item at all.
	 */
	.panel {
		position: absolute;
		top: 100%;
		left: -8px;
		z-index: 1;
		padding-top: 6px;
		visibility: hidden;
		opacity: 0;
		transform: translateY(-4px);
		transition:
			opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
			transform 150ms cubic-bezier(0.4, 0, 0.2, 1),
			visibility 150ms;
	}

	.item:hover > .panel,
	.item:focus-within > .panel,
	.item[data-mh-open] > .panel {
		visibility: visible;
		opacity: 1;
		transform: translateY(0);
	}

	/* With the script running, hover and focus alone no longer open a panel —
	   the attribute is the single source of truth, so Escape actually closes. */
	[data-mh-js] .item:hover > .panel,
	[data-mh-js] .item:focus-within > .panel {
		visibility: hidden;
		opacity: 0;
		transform: translateY(-4px);
	}

	[data-mh-js] .item[data-mh-open] > .panel {
		visibility: visible;
		opacity: 1;
		transform: translateY(0);
	}

	.card {
		display: flex;
		gap: 28px;
		padding: 16px;
		border: 1px solid var(--mh-border);
		border-radius: 14px;
		background: var(--mh-surface);
		box-shadow:
			0 2px 4px rgb(0 0 0 / 4%),
			0 16px 40px -12px rgb(0 0 0 / 18%);
	}

	.group-head {
		margin-bottom: 6px;
		padding-inline: 12px;
		color: var(--mh-muted);
		font-size: 0.75rem;
		font-weight: 600;
		letter-spacing: 0.06em;
		text-transform: uppercase;
	}

	.group-list {
		display: grid;
		gap: 2px;
	}

	.cell {
		display: block;
		width: 240px;
		padding: 8px 12px;
		border-radius: 10px;
		color: var(--mh-ink);
		text-decoration: none;
		transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	.cell:hover {
		background: color-mix(in srgb, var(--mh-ink) 5%, transparent);
	}

	.cell-title {
		display: block;
		font-size: 1rem;
		font-weight: 500;
		line-height: 1.35;
	}

	.cell[aria-current="page"] .cell-title {
		color: var(--mh-accent);
	}

	.cell-desc {
		display: block;
		color: var(--mh-muted);
		font-size: 0.875rem;
		line-height: 1.3;
	}

	/* --- right-hand actions ---------------------------------------------------- */

	.actions {
		display: none;
		flex: none;
		gap: 8px;
		margin-inline-start: auto;
	}

	.ghost,
	.cta {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		min-height: 44px;
		padding: 6px 12px;
		border-radius: 8px;
		font-size: 0.875rem;
		font-weight: 500;
		text-decoration: none;
		white-space: nowrap;
		transition:
			background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
			filter 150ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	.ghost {
		color: var(--mh-ink);
	}

	.ghost:hover {
		background: color-mix(in srgb, var(--mh-ink) 6%, transparent);
	}

	.cta {
		background: var(--mh-cta-bg);
		color: var(--mh-cta-ink);
	}

	.cta:hover {
		filter: brightness(1.18);
	}

	.wide {
		width: 100%;
	}

	/* --- burger ----------------------------------------------------------------- */

	.burger {
		display: inline-flex;
		flex: none;
		align-items: center;
		justify-content: center;
		width: 44px;
		height: 44px;
		margin-inline-start: auto;
		padding: 0;
		border: 0;
		border-radius: 10px;
		background: none;
		color: var(--mh-ink);
		cursor: pointer;
	}

	.burger:hover {
		background: color-mix(in srgb, var(--mh-ink) 6%, transparent);
	}

	.bars {
		width: 22px;
		height: 22px;
		fill: none;
		stroke: currentColor;
		stroke-width: 1.8;
		stroke-linecap: round;
	}

	.bar-a,
	.bar-b {
		transform-origin: center;
		transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	[data-mh-sheet-open] .bar-a {
		transform: translateY(3px) rotate(45deg);
	}

	[data-mh-sheet-open] .bar-b {
		transform: translateY(-3px) rotate(-45deg);
	}

	/* --- mobile sheet -------------------------------------------------------------- */

	.sheet {
		position: fixed;
		inset: var(--mh-h) 0 0;
		z-index: calc(var(--mh-z) - 1);
		overflow-y: auto;
		padding: 16px var(--mh-pad) 32px;
		background: var(--mh-surface);
	}

	.sheet-nav {
		display: flex;
		flex-direction: column;
		gap: 2px;
		max-width: var(--mh-max);
		margin-inline: auto;
	}

	.sheet-link,
	.sheet-summary {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 8px;
		min-height: 48px;
		padding: 10px 12px;
		border-radius: 10px;
		color: var(--mh-ink);
		font-size: 1.0625rem;
		font-weight: 500;
		text-decoration: none;
		cursor: pointer;
		list-style: none;
	}

	.sheet-summary::-webkit-details-marker {
		display: none;
	}

	.sheet-link:hover,
	.sheet-summary:hover {
		background: color-mix(in srgb, var(--mh-ink) 5%, transparent);
	}

	.sheet-link[aria-current="page"] {
		color: var(--mh-accent);
	}

	.sheet-group[open] > .sheet-summary .chev {
		transform: rotate(180deg);
	}

	.sheet-sub {
		display: grid;
		gap: 2px;
		margin: 2px 0 8px;
		padding-inline-start: 12px;
		border-inline-start: 2px solid var(--mh-border);
	}

	.sheet-link.is-sub {
		min-height: 44px;
		font-size: 1rem;
		font-weight: 400;
		color: var(--mh-muted);
	}

	.sheet-link.is-sub:hover {
		color: var(--mh-ink);
	}

	.sheet-actions {
		display: grid;
		gap: 8px;
		margin-top: 16px;
		padding-top: 16px;
		border-top: 1px solid var(--mh-border);
	}

	/* Actions with no navigation above them: no rule separating them from
	   nothing. */
	.sheet-actions:first-child {
		margin-top: 0;
		padding-top: 0;
		border-top: 0;
	}

	/*
	 * Closed by default only once the script is running. Without JavaScript the
	 * sheet stays in the flow below the bar, every destination reachable, and
	 * the `<details>` groups still open and close on their own.
	 */
	[data-mh-js] .sheet {
		display: none;
	}

	[data-mh-js][data-mh-sheet-open] .sheet {
		display: block;
	}

	[data-mh-js] .sheet {
		position: fixed;
	}

	.header:not([data-mh-js]) .sheet {
		position: static;
		inset: auto;
		border-bottom: 1px solid var(--mh-border);
	}

	/* --- focus ---------------------------------------------------------------------- */

	/* One ring for every control. The reference leaves the browser default,
	   which computes to about 0.67px. */
	.brand:focus-visible,
	.link:focus-visible,
	.trigger:focus-visible,
	.cell:focus-visible,
	.ghost:focus-visible,
	.cta:focus-visible,
	.burger:focus-visible,
	.sheet-link:focus-visible,
	.sheet-summary:focus-visible {
		outline: 3px solid var(--mh-accent);
		outline-offset: 2px;
	}

	/* --- desktop breakpoint: measured at exactly 1024px ------------------------------- */

	@media (min-width: 768px) {
		.header {
			--mh-pad: 32px;
		}
	}

	@media (min-width: 1024px) {
		.header {
			--mh-pad: 64px;
		}

		.brand {
			flex: none;
		}

		.brand-name {
			white-space: nowrap;
		}

		.desk {
			display: block;
			margin-inline-start: 24px;
		}

		.actions {
			display: flex;
		}

		.burger,
		.sheet {
			display: none;
		}

		/* Keeps the no-JS rule from resurrecting the sheet on desktop. */
		.header:not([data-mh-js]) .sheet {
			display: none;
		}
	}

	@media (min-width: 1280px) {
		.header {
			--mh-pad: 80px;
		}

		.desk {
			margin-inline-start: 40px;
		}
	}

	/* --- reduced motion ----------------------------------------------------------------- */

	/*
	 * Everything still happens — the bar still frosts, panels still open, the
	 * sheet still appears — it just happens instantly. The reference honours
	 * none of this; its 300ms header transition runs regardless.
	 */
	@media (prefers-reduced-motion: reduce) {
		.bar,
		.panel,
		.chev,
		.link,
		.trigger,
		.cell,
		.ghost,
		.cta,
		.bar-a,
		.bar-b {
			transition-duration: 0.01ms;
		}
	}
</style>

<script>
	/**
	 * One controller per instance, and one boot per page view.
	 *
	 * A header is installed into a shared layout rather than dropped onto one
	 * page, which changes what the script has to survive. A destination project
	 * running <ClientRouter /> swaps the document without re-evaluating this
	 * module, so booting hangs off `astro:page-load` as well as first load;
	 * booting twice over the same element must be free, so init is idempotent;
	 * and the listeners this puts on `document` and `window` would outlive
	 * every swap, so they are bound to an AbortController that is aborted
	 * before the document is replaced.
	 */

	/*
	 * Body scroll locking is a page-level concern, so it is counted at module
	 * scope rather than per instance. Two headers on one page — the catalog's
	 * own two-instance test does exactly this — would otherwise have the second
	 * one's close restore the page scroll while the first still has its sheet
	 * open over it.
	 */
	let scrollLocks = 0;
	let savedOverflow = "";

	function lockScroll() {
		if (scrollLocks++ === 0) {
			savedOverflow = document.body.style.overflow;
			document.body.style.overflow = "hidden";
		}
	}

	function releaseScroll() {
		if (scrollLocks === 0) return;
		// Restore exactly what was there, rather than assuming "visible".
		if (--scrollLocks === 0) document.body.style.overflow = savedOverflow;
	}

	function initStickyMegaMenuHeader(root: HTMLElement) {
		// Idempotent: a second boot over the same element is a no-op rather
		// than a second set of listeners on every trigger.
		if (root.dataset.mhReady === "") return;

		const bar = root.querySelector<HTMLElement>("[data-mh-bar]");
		const items = Array.from(root.querySelectorAll<HTMLElement>("[data-mh-item]"));
		const burger = root.querySelector<HTMLButtonElement>("[data-mh-burger]");
		const sheet = root.querySelector<HTMLElement>("[data-mh-sheet]");
		if (!bar) return;

		root.dataset.mhReady = "";

		// Enables the JS-driven states. Without this the CSS keeps the panels on
		// hover/focus-within and the sheet in the flow, so nothing is unreachable.
		root.setAttribute("data-mh-js", "");

		/* Every listener below carries this signal — including the document-level
		   dismissal handlers, which are the ones that would otherwise accumulate
		   one set per client-side navigation. */
		const controller = new AbortController();
		const { signal } = controller;

		/* ---- scroll state: flips at the first pixel, as measured ---- */
		let frame = 0;
		function measure() {
			const past = window.scrollY > 0;
			if (past === root.hasAttribute("data-mh-scrolled")) return;
			if (past) root.setAttribute("data-mh-scrolled", "");
			else root.removeAttribute("data-mh-scrolled");
		}
		function schedule() {
			if (frame) return;
			frame = requestAnimationFrame(() => {
				frame = 0;
				measure();
			});
		}
		window.addEventListener("scroll", schedule, { passive: true, signal });
		measure();

		/* ---- mega panels ---- */
		let openItem: HTMLElement | null = null;
		let closeTimer: ReturnType<typeof setTimeout> | undefined;

		function setOpen(item: HTMLElement | null) {
			if (openItem === item) return;
			/* One panel at a time within this instance, and never across two:
			   `items` was queried from this root, so another header's panels are
			   not in the list to be closed. */
			for (const other of items) {
				const on = other === item;
				other.toggleAttribute("data-mh-open", on);
				const trigger = other.querySelector<HTMLButtonElement>("[data-mh-trigger]");
				trigger?.setAttribute("aria-expanded", String(on));
			}
			openItem = item;
		}

		// A short grace period, so the pointer can cross the gap between the
		// trigger and the panel below it without the panel vanishing underneath.
		function scheduleClose() {
			clearTimeout(closeTimer);
			closeTimer = setTimeout(() => setOpen(null), 260);
		}
		function cancelClose() {
			clearTimeout(closeTimer);
		}

		for (const item of items) {
			const trigger = item.querySelector<HTMLButtonElement>("[data-mh-trigger]");
			if (!trigger) continue;

			// Hover opens, matching the reference. Moving to another trigger
			// swaps which panel is open rather than stacking two.
			item.addEventListener(
				"pointerenter",
				() => {
					cancelClose();
					setOpen(item);
				},
				{ signal },
			);
			item.addEventListener("pointerleave", scheduleClose, { signal });

			// Enter and Space both reach this through a <button>'s native click.
			trigger.addEventListener(
				"click",
				() => {
					cancelClose();
					setOpen(openItem === item ? null : item);
				},
				{ signal },
			);

			// Tabbing out of the last link in a panel closes it, so focus never
			// sits behind an invisible layer.
			item.addEventListener(
				"focusout",
				(event) => {
					const next = event.relatedTarget as Node | null;
					if (next && item.contains(next)) return;
					if (openItem === item) setOpen(null);
				},
				{ signal },
			);
		}

		/* ---- mobile sheet ---- */
		let sheetOpen = false;

		function setSheet(next: boolean) {
			if (!burger || !sheet) return;
			if (sheetOpen === next) return;
			sheetOpen = next;
			root.toggleAttribute("data-mh-sheet-open", next);
			burger.setAttribute("aria-expanded", String(next));
			if (next) lockScroll();
			else releaseScroll();
		}

		burger?.addEventListener("click", () => setSheet(!sheetOpen), { signal });
		sheet?.addEventListener(
			"click",
			(event) => {
				if ((event.target as HTMLElement).closest("a")) setSheet(false);
			},
			{ signal },
		);

		/* ---- shared dismissal ---- */
		document.addEventListener(
			"keydown",
			(event) => {
				if (event.key !== "Escape") return;
				if (openItem) {
					const trigger = openItem.querySelector<HTMLButtonElement>("[data-mh-trigger]");
					setOpen(null);
					trigger?.focus();
				} else if (sheetOpen) {
					setSheet(false);
					burger?.focus();
				}
			},
			{ signal },
		);

		document.addEventListener(
			"pointerdown",
			(event) => {
				if (root.contains(event.target as Node)) return;
				setOpen(null);
				if (sheetOpen) setSheet(false);
			},
			{ signal },
		);

		// Crossing the breakpoint with the sheet open would otherwise leave the
		// body locked and a hidden sheet still flagged open.
		const wide = window.matchMedia("(min-width: 1024px)");
		const onChange = () => {
			if (wide.matches && sheetOpen) setSheet(false);
			if (!wide.matches && openItem) setOpen(null);
		};
		if (typeof wide.addEventListener === "function") {
			wide.addEventListener("change", onChange, { signal });
		}

		/*
		 * Astro replaces the document on a client-side navigation. Close first,
		 * so a sheet that was open does not leave `overflow: hidden` on the body
		 * of the page being navigated to; then detach, and drop the ready flag
		 * so a re-used element can be booted again.
		 */
		document.addEventListener(
			"astro:before-swap",
			() => {
				if (frame) cancelAnimationFrame(frame);
				clearTimeout(closeTimer);
				setSheet(false);
				setOpen(null);
				controller.abort();
				delete root.dataset.mhReady;
			},
			{ once: true, signal },
		);
	}

	const boot = () =>
		document
			.querySelectorAll<HTMLElement>("[data-mh-root]")
			.forEach(initStickyMegaMenuHeader);

	/* Astro's own <script> is a deferred module, so the DOM is already parsed
	   by the time this runs. The readyState check is for the case where this
	   file is copied into a project that inlines it some other way. */
	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	/* Fires on every view transition in a project using <ClientRouter />, and
	   never at all in one that does not. Registered once per module, so it
	   cannot accumulate. */
	document.addEventListener("astro:page-load", boot);
</script>

===== END src/components/StickyMegaMenuHeader.astro =====

===== FILE (required): src/components/FramedHero.astro =====
---
/**
 * FramedHero — a full-viewport hero drawn inside a rounded panel inset from
 * the page edge, with a background image under a directional scrim, a headline
 * that rises line by line, and a row of actions.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * and no bundled assets: drop it into any Astro project and render it.
 *
 * With no image passed the panel paints its own gradient scene, so the hero
 * has nothing to download and still reads as a photograph-backed block.
 *
 * Measured behaviour:
 *   frame         12px inset around a 20px-radius panel, ring + drop + inset shadow
 *   panel         min-height 100vh, content vertically centred, text left
 *   scrim         left-to-right wash so the copy holds contrast over any image
 *   headline      each line masked and lifted 100%, staggered
 *   copy          subtitle and kicker follow the headline, then the actions
 *   actions       pill buttons, recolour over 200ms
 *
 * Several instances can share a page: the reveal observer is built per root
 * and every element it touches belongs to that root.
 */

export interface HeroAction {
	label: string;
	/**
	 * Destination. Omit it — or pass an empty string — and the action renders
	 * as a non-interactive pill rather than a button that goes nowhere.
	 */
	href?: string;
	/** Filled or outlined. Defaults to filled for the first action. */
	variant?: "solid" | "outline";
	/** Open in a new tab, with the matching rel. */
	external?: boolean;
}

interface Props {
	/** Small wordmark above the headline. Empty string hides it. */
	brand?: string;
	/**
	 * The headline. Split on newlines: each line is masked and revealed
	 * separately, which is what produces the staggered rise.
	 */
	heading?: string;
	/** Paragraph under the headline. Empty string hides it. */
	subtitle?: string;
	/** Small tracked line under the subtitle. Empty string hides it. */
	kicker?: string;
	/** Buttons at the foot of the copy. `[]` hides the row. */
	actions?: HeroAction[];
	/** Background image URL. Omit for the drawn gradient scene. */
	image?: string;
	/** `srcset` for the background image, when you have several sizes. */
	imageSrcset?: string;
	/** `sizes` to go with `imageSrcset`. */
	imageSizes?: string;
	/** Alternative text. Leave empty when the image is decorative. */
	imageAlt?: string;
	/** `object-position` for the background image. */
	imagePosition?: string;
	/** The wash over the image. Any CSS gradient. */
	scrim?: string;
	/** Fill the viewport. Off lets the hero be only as tall as its content. */
	fullHeight?: boolean;
	/** Page colour behind the inset panel. */
	surround?: string;
	/** Panel colour, seen where the image does not reach. */
	panelSurface?: string;
	/** Headline and action colour. */
	ink?: string;
	/** Tint of the drawn scene when no image is passed. */
	accent?: string;
	/** Corner radius of the panel. */
	radius?: string;
	/** Reveal the copy as the hero arrives. */
	reveal?: boolean;
	/** Heading level. A hero is usually the page's h1. */
	headingLevel?: "h1" | "h2";
	class?: string;
	id?: string;
}

const {
	/* No copy of its own: the brand, headline, lines and actions are the
	   caller's. The catalog demo passes them through the registry's props. */
	brand = "",
	heading = "",
	subtitle = "",
	kicker = "",
	actions = [],
	image,
	imageSrcset,
	imageSizes,
	imageAlt = "",
	imagePosition = "right center",
	scrim = "linear-gradient(to right, rgb(0 0 0 / 0.62) 0%, rgb(0 0 0 / 0.28) 45%, transparent 100%)",
	fullHeight = true,
	surround = "#eef0f2",
	panelSurface = "#121317",
	ink = "#ffffff",
	accent = "#2f4bd8",
	radius = "20px",
	reveal = true,
	headingLevel = "h1",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;
const lines = heading ? heading.split("\n").map((l) => l.trim()).filter(Boolean) : [];

const isDestination = (href?: string) => {
	const trimmed = href?.trim();
	return !!trimmed && trimmed !== "#";
};

/* An action with no label has nothing to say: it is not drawn. */
const actionList = actions.filter((action) => !!action?.label);

/* Index the copy blocks so each can follow the last without hard-coding. */
const copyDelay = (step: number) => `${lines.length * 110 + step * 90}ms`;

/* A backdrop with nothing on it is not a hero: render nothing. */
const hasContent = !!brand || lines.length > 0 || !!subtitle || !!kicker || actionList.length > 0;
---

{hasContent && (
<section
	class:list={["frame", className]}
	id={id}
	data-framed-hero
	data-reveal={reveal ? "" : undefined}
	data-full={fullHeight ? "" : undefined}
	style={`--fh-surround:${surround};--fh-panel:${panelSurface};--fh-ink:${ink};--fh-accent:${accent};--fh-radius:${radius};--fh-scrim:${scrim};--fh-image-pos:${imagePosition};`}
>
	<div class="panel">
		{
			image ? (
				<img
					class="backdrop"
					src={image}
					srcset={imageSrcset}
					sizes={imageSizes}
					alt={imageAlt}
					decoding="async"
					fetchpriority="high"
				/>
			) : (
				<div class="scene" aria-hidden="true" />
			)
		}

		<div class="scrim" aria-hidden="true"></div>

		<div class="content">
			<div class="copy">
				{
					brand && (
						<p class="brand" data-lift style="--d:0ms">
							{brand}
						</p>
					)
				}

				{
					lines.length > 0 && (
						<Heading class="heading">
							{lines.map((line, i) => (
								<span class="line-mask">
									<span class="line" style={`--i:${i}`}>
										{line}
									</span>
								</span>
							))}
						</Heading>
					)
				}

				{
					subtitle && (
						<p class="subtitle" data-lift style={`--d:${copyDelay(0)}`}>
							{subtitle}
						</p>
					)
				}

				{
					kicker && (
						<p class="kicker" data-lift style={`--d:${copyDelay(1)}`}>
							{kicker}
						</p>
					)
				}

				{
					actionList.length > 0 && (
						<div class="actions" data-lift style={`--d:${copyDelay(2)}`}>
							{actionList.map((action, i) => {
								const variant = action.variant ?? (i === 0 ? "solid" : "outline");
								return isDestination(action.href) ? (
									<a
										class:list={["action", variant]}
										href={action.href}
										target={action.external ? "_blank" : undefined}
										rel={action.external ? "noopener noreferrer" : undefined}
									>
										{action.label}
									</a>
								) : (
									<span class:list={["action", variant, "action-static"]}>
										{action.label}
									</span>
								);
							})}
						</div>
					)
				}
			</div>
		</div>
	</div>
</section>
)}

<style>
	.frame {
		--fh-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
			"Helvetica Neue", Arial, sans-serif;

		/* 30px at a 390px viewport, 60px from about 1280px up. */
		--fh-display: clamp(1.875rem, 0.85rem + 3.2vw, 3.75rem);
		--fh-body: clamp(1rem, 0.94rem + 0.2vw, 1.125rem);

		--fh-ease: cubic-bezier(0.16, 1, 0.3, 1);

		box-sizing: border-box;
		display: flex;
		padding: 12px;
		background: var(--fh-surround);
		font-family: var(--fh-font);
	}

	.frame :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.panel {
		position: relative;
		flex: 1;
		min-width: 0;
		display: flex;
		overflow: hidden;
		border-radius: var(--fh-radius);
		background: var(--fh-panel);
		color: var(--fh-ink);
		box-shadow:
			0 0 0 1px rgb(0 0 0 / 0.05),
			0 8px 30px rgb(0 0 0 / 0.12),
			inset 0 1px 0 rgb(255 255 255 / 0.1);
	}

	.frame[data-full] .panel {
		min-height: calc(100vh - 24px);
		min-height: calc(100svh - 24px);
	}

	/* ---------- backdrop ---------- */

	.backdrop {
		position: absolute;
		inset: 0;
		z-index: 0;
		width: 100%;
		height: 100%;
		object-fit: cover;
		object-position: var(--fh-image-pos);
	}

	/* Drawn stand-in for the photograph: a lit corner over a deep ground. */
	.scene {
		position: absolute;
		inset: 0;
		z-index: 0;
		background:
			radial-gradient(
				ellipse 62% 78% at 78% 38%,
				color-mix(in srgb, var(--fh-accent) 85%, #fff 15%) 0%,
				color-mix(in srgb, var(--fh-accent) 55%, transparent) 38%,
				transparent 72%
			),
			radial-gradient(
				ellipse 70% 60% at 22% 88%,
				color-mix(in srgb, var(--fh-accent) 30%, transparent) 0%,
				transparent 70%
			),
			linear-gradient(140deg, #16171d 0%, var(--fh-panel) 55%, #0c0d11 100%);
	}

	.scrim {
		position: absolute;
		inset: 0;
		z-index: 1;
		pointer-events: none;
		background: var(--fh-scrim);
	}

	/* ---------- content ---------- */

	.content {
		position: relative;
		z-index: 2;
		display: flex;
		flex-direction: column;
		justify-content: center;
		width: 100%;
		min-height: 0;
		padding: 56px 32px;
	}

	.copy {
		width: 100%;
		max-width: 48rem;
	}

	.brand {
		margin: 0 0 18px;
		font-size: 1.375rem;
		font-weight: 700;
		letter-spacing: -0.02em;
		color: var(--fh-ink);
	}

	.heading {
		margin: 0;
		font-size: var(--fh-display);
		line-height: 1.1;
		font-weight: 800;
		letter-spacing: -0.025em;
		text-wrap: balance;
	}

	/*
	 * Each line gets a clipping box of its own. The bottom padding, cancelled
	 * by an equal negative margin, gives descenders the room the clip would
	 * otherwise cut without changing where the line sits.
	 */
	.line-mask {
		display: block;
		overflow: hidden;
		padding-bottom: 0.08em;
		margin-bottom: -0.08em;
	}

	.line {
		display: block;
	}

	.subtitle {
		max-width: 36rem;
		margin: 24px 0 0;
		font-size: var(--fh-body);
		line-height: 1.6;
		color: color-mix(in srgb, var(--fh-ink) 72%, transparent);
		text-wrap: pretty;
	}

	.kicker {
		margin: 12px 0 0;
		font-size: 0.625rem;
		font-weight: 600;
		letter-spacing: 0.2em;
		text-transform: uppercase;
		color: color-mix(in srgb, var(--fh-ink) 60%, transparent);
	}

	.actions {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
		margin-top: 32px;
	}

	.action {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: 100%;
		padding: 14px 28px;
		border: 1px solid transparent;
		border-radius: 999px;
		font-size: 0.875rem;
		font-weight: 700;
		line-height: 1.2;
		text-align: center;
		text-decoration: none;
		transition:
			background-color 200ms cubic-bezier(0.4, 0, 0.2, 1),
			border-color 200ms cubic-bezier(0.4, 0, 0.2, 1),
			color 200ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	.solid {
		background: var(--fh-ink);
		color: #0a0b0e;
	}

	.outline {
		border-color: color-mix(in srgb, var(--fh-ink) 70%, transparent);
		background: rgb(0 0 0 / 0.25);
		color: var(--fh-ink);
	}

	/*
	 * An action with no destination. It is a <span>, so it is already out of
	 * the tab order and unclickable; this makes that legible rather than
	 * leaving a button that looks live.
	 */
	.action-static {
		opacity: 0.55;
		cursor: default;
		transition: none;
	}

	.action:focus-visible {
		outline: 2px solid var(--fh-ink);
		outline-offset: 3px;
	}

	/* ---------- reveal ---------- */

	.frame[data-reveal] .line {
		transform: translateY(100%);
		opacity: 0;
		transition:
			transform 800ms var(--fh-ease) calc(var(--i) * 110ms),
			opacity 800ms var(--fh-ease) calc(var(--i) * 110ms);
	}

	.frame[data-reveal] [data-lift] {
		opacity: 0;
		transform: translateY(16px);
		transition:
			opacity 700ms var(--fh-ease) var(--d, 0ms),
			transform 700ms var(--fh-ease) var(--d, 0ms);
	}

	.frame[data-reveal] [data-in] .line,
	.frame[data-reveal] [data-lift][data-in] {
		transform: none;
		opacity: 1;
	}

	/* ---------- breakpoints ---------- */

	@media (min-width: 640px) {
		.actions {
			flex-direction: row;
			flex-wrap: wrap;
		}

		.action {
			width: auto;
		}
	}

	@media (min-width: 768px) {
		.content {
			padding: 56px;
		}
	}

	@media (min-width: 1024px) {
		.content {
			padding: 64px 80px;
		}

		.brand {
			margin-bottom: 20px;
		}
	}

	@media (hover: hover) and (pointer: fine) {
		a.solid:hover {
			background: color-mix(in srgb, var(--fh-ink) 90%, transparent);
		}

		a.outline:hover {
			background: var(--fh-ink);
			color: #0a0b0e;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.frame[data-reveal] :is(.line, [data-lift]) {
			opacity: 1;
			transform: none;
			transition: none;
		}

		.action {
			transition: none;
		}
	}
</style>

<noscript>
	<style>
		.frame[data-reveal] :is(.line, [data-lift]) {
			opacity: 1;
			transform: none;
		}
	</style>
</noscript>

<script>
	/*
	 * One observer per instance, closed over that instance's own elements, so
	 * two heroes on a page reveal independently.
	 */
	const setup = (root: HTMLElement) => {
		if (!root.hasAttribute("data-reveal")) return;

		const blocks = [...root.querySelectorAll<HTMLElement>(".heading, [data-lift]")];
		if (blocks.length === 0) return;

		if (!("IntersectionObserver" in window)) {
			blocks.forEach((block) => block.setAttribute("data-in", ""));
			return;
		}

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					if (!entry.isIntersecting) continue;
					entry.target.setAttribute("data-in", "");
					observer.unobserve(entry.target);
				}
			},
			/*
			 * A hero is normally in view on load, so this fires immediately. The
			 * large top margin keeps it correct anywhere else on the page: an
			 * observer reports only a *change* in intersection, so a block
			 * jumped over would read 0 both times and never reveal.
			 */
			{ rootMargin: "10000px 0px -5% 0px", threshold: 0 },
		);

		blocks.forEach((block) => observer.observe(block));
	};

	document.querySelectorAll<HTMLElement>("[data-framed-hero]").forEach(setup);
</script>

===== END src/components/FramedHero.astro =====

===== FILE (required): public/framed-hero/backdrop-1440.webp =====
[binary asset — download https://astro.baysixmedia.com/framed-hero/backdrop-1440.webp and save it to public/framed-hero/backdrop-1440.webp]

===== FILE (required): public/framed-hero/backdrop-1920.webp =====
[binary asset — download https://astro.baysixmedia.com/framed-hero/backdrop-1920.webp and save it to public/framed-hero/backdrop-1920.webp]

===== FILE (required): src/components/StaggeredLogoCloud.astro =====
---
/**
 * StaggeredLogoCloud — a "trusted by" logo row whose whole set is replaced on a
 * timer, each logo leaving to the right and arriving from the left a beat after
 * the one before it.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * no webfonts, no images, no network requests: drop it into any Astro project
 * and render it. Static-only — no SSR, no endpoints.
 *
 * Design behaviour is modelled on the public Tailark "Trusted by people from"
 * section (https://pro.tailark.com/), from the measurements recorded in
 * StaggeredLogoCloud.md. No source, markup, CSS, script, branding, logo or
 * artwork from that site is used here, and the demo wordmarks are original.
 *
 * The moving parts, and why they are shaped this way:
 *
 *   - Every group is a grid layer stacked in the same `grid-area`, so the eight
 *     cells an outgoing logo leaves are the exact cells its replacement arrives
 *     in. Nothing is measured at runtime and nothing reflows mid-transition.
 *   - Stagger is one custom property per cell (`--slc-i`) feeding a
 *     `transition-delay`. No per-frame scripting, no animation library.
 *   - Groups are normalised to a common cell count, so a group of six and a
 *     group of eight occupy identical geometry and a short group cannot make
 *     the page jump when it takes its turn.
 *   - The wrap from the last group to the first is `(i + 1) % count` and
 *     nothing else. There is no branch at the boundary, so there is nothing
 *     that could look different there.
 *
 * Several instances can share a page: every query is scoped to the instance
 * root, ids are minted per instance, and all state lives in the controller's
 * closure.
 */

export interface LogoCloudItem {
	/** Stable identity. Used for the cell's key and for the rendered id. */
	id: string;
	/** The company or product name. Also the accessible name, and the wordmark. */
	name: string;
	/** Image URL. Omit to render a text wordmark instead. */
	src?: string;
	/** Alt text. Defaults to the name; an empty string marks it decorative. */
	alt?: string;
	/** Optional destination. An unsafe value renders as inert, non-linked text. */
	href?: string;
	/** Opens in a new tab with the matching rel. */
	external?: boolean;
	/** Intrinsic width, so the browser can reserve the right box. */
	width?: number;
	/** Intrinsic height, so the browser can reserve the right box. */
	height?: number;
	/** `invert` flips a dark-on-light image under a dark colour scheme. */
	darkMode?: "none" | "invert";
	/**
	 * Treatment for the text wordmark drawn when there is no `src`.
	 *
	 * Not in the reference, which ships real logos. It exists so a library
	 * preview can show eight visually distinct marks without copying anybody's
	 * artwork, and so a destination project has something reasonable to render
	 * before its own assets arrive.
	 */
	wordmark?: "plain" | "bold" | "wide" | "dot" | "slash" | "box";
}

export interface LogoCloudGroup {
	/** Stable identity. Used for the group's rendered id. */
	id: string;
	logos: LogoCloudItem[];
}

export interface StaggeredLogoCloudProps {
	/** The sets of logos to cycle through. */
	groups: LogoCloudGroup[];
	/** The muted line above the logos, and the region's accessible name. */
	label?: string;
	/** Advance through the groups on a timer. */
	autoplay?: boolean;
	/** Milliseconds each group is held before the next one arrives. */
	interval?: number;
	/** Hold the timer while the pointer is over the component. */
	pauseOnHover?: boolean;
	/** Render the play/pause control. Only ever shown when autoplay can run. */
	showPauseControl?: boolean;
	/** Accessible name for the control while it would pause. */
	pauseLabel?: string;
	/** Accessible name for the control while it would resume. */
	playLabel?: string;
	class?: string;
	id?: string;
}

interface Props extends StaggeredLogoCloudProps {}

const {
	groups,
	label = "Trusted by people from",
	autoplay = true,
	interval = 4000,
	pauseOnHover = true,
	showPauseControl = true,
	pauseLabel = "Pause logo rotation",
	playLabel = "Resume logo rotation",
	class: className,
	id,
}: Props = Astro.props;

/*
 * Item data is treated as untrusted: it typically comes from a CMS or a
 * content collection, and an href becomes an attribute on every render. Only
 * plain paths, fragments and http/https/mailto/tel survive; anything else —
 * `javascript:` and `data:` above all — renders as inert text rather than a
 * link that runs.
 */
const SAFE_SCHEME = /^(https?:|mailto:|tel:)/i;

/** Control characters are stripped first: "java\tscript:" is a scheme too. */
function stripControls(value: string): string {
	return [...value]
		.filter((ch) => {
			const code = ch.codePointAt(0) ?? 0;
			return code > 31 && code !== 127;
		})
		.join("");
}

function safeHref(value: unknown): string | undefined {
	if (typeof value !== "string") return undefined;
	const raw = value.trim();
	if (raw === "") return undefined;
	const bare = stripControls(raw).trim();
	if (bare === "") return undefined;
	if (
		bare.startsWith("/") ||
		bare.startsWith("#") ||
		bare.startsWith("./") ||
		bare.startsWith("../")
	) {
		return bare;
	}
	if (SAFE_SCHEME.test(bare)) return bare;
	/* A bare relative path has no scheme before its first slash. */
	const colon = bare.indexOf(":");
	const slash = bare.indexOf("/");
	if (colon === -1 || (slash !== -1 && slash < colon)) return bare;
	return undefined;
}

/**
 * Image sources are checked more narrowly than links.
 *
 * `data:` is allowed here and refused for hrefs, because an inline SVG is a
 * legitimate image and a legitimate navigation target is never a data URL.
 */
function safeSrc(value: unknown): string | undefined {
	if (typeof value !== "string") return undefined;
	const raw = value.trim();
	if (raw === "") return undefined;
	const bare = stripControls(raw);
	if (/^\s*(javascript|vbscript):/i.test(bare)) return undefined;
	return raw;
}

const WORDMARKS = new Set(["plain", "bold", "wide", "dot", "slash", "box"]);

interface ResolvedLogo {
	key: string;
	name: string;
	src?: string;
	alt: string;
	href?: string;
	external: boolean;
	width?: number;
	height?: number;
	invert: boolean;
	wordmark: string;
}

const seenGroupIds = new Set<string>();

/*
 * Groups with nothing usable in them are dropped rather than rendered as empty
 * rows. A set of no logos is not an empty set of logos — it is a data mistake,
 * and showing a blank beat in the rotation would make it look deliberate.
 */
const resolvedGroups = (Array.isArray(groups) ? groups : [])
	.map((group) => {
		if (!group || typeof group.id !== "string" || group.id.trim() === "") return null;
		const logos: ResolvedLogo[] = (Array.isArray(group.logos) ? group.logos : [])
			.filter(
				(logo): logo is LogoCloudItem =>
					Boolean(logo) && typeof logo.id === "string" && typeof logo.name === "string",
			)
			.filter((logo) => logo.id.trim() !== "" && logo.name.trim() !== "")
			.map((logo) => {
				const href = safeHref(logo.href);
				const wordmark =
					typeof logo.wordmark === "string" && WORDMARKS.has(logo.wordmark)
						? logo.wordmark
						: "plain";
				return {
					key: logo.id.trim(),
					name: logo.name.trim(),
					src: safeSrc(logo.src),
					alt: typeof logo.alt === "string" ? logo.alt : logo.name.trim(),
					href,
					external: href !== undefined && logo.external === true,
					width: Number.isFinite(logo.width) ? Number(logo.width) : undefined,
					height: Number.isFinite(logo.height) ? Number(logo.height) : undefined,
					invert: logo.darkMode === "invert",
					wordmark,
				};
			});
		if (logos.length === 0) return null;
		const groupId = group.id.trim();
		if (seenGroupIds.has(groupId)) return null;
		seenGroupIds.add(groupId);
		return { key: groupId, logos };
	})
	.filter((group): group is { key: string; logos: ResolvedLogo[] } => group !== null);

/*
 * One cell count for every group, taken from the largest.
 *
 * A group with fewer logos is padded with empty cells rather than laid out on
 * its own terms. Identical geometry is what lets the stage keep its height and
 * lets an outgoing logo hand its exact cell to the one replacing it.
 */
const cellCount = resolvedGroups.reduce((max, group) => Math.max(max, group.logos.length), 0);

const groupCount = resolvedGroups.length;
/* One group is a logo row, not a rotation: no timer, no control, no layers. */
const cycles = groupCount > 1;

/* Floored so a mistyped interval cannot produce a strobe. */
const safeInterval = Math.max(1500, Number(interval) || 4000);

/*
 * Random rather than sequential: a module-level counter is shared by every
 * render in the same server process, which is fine for one page and wrong the
 * moment two pages are built in parallel and compared.
 */
const uid = id ?? `slc-${Math.random().toString(36).slice(2, 9)}`;
const stageId = `${uid}-stage`;
---

{
	groupCount > 0 && (
		<section
			class:list={["slc", className]}
			id={id}
			style={`--slc-cols:${cellCount}`}
			data-slc-root
			data-slc-cycles={cycles ? "1" : "0"}
			data-slc-autoplay={autoplay ? "1" : "0"}
			data-slc-interval={String(safeInterval)}
			data-slc-pause-hover={pauseOnHover ? "1" : "0"}
			data-slc-control={showPauseControl ? "1" : "0"}
			data-slc-pause-label={pauseLabel}
			data-slc-play-label={playLabel}
			aria-label={label}
		>
			<div class="slc-inner">
				{/*
				    Three columns rather than a centred flex row: the label stays
				    optically centred in the section whether or not the control is
				    rendered, and the control can never overlap it at any width.
				*/}
				<div class="slc-head">
					<span class="slc-spacer" aria-hidden="true" />
					<p class="slc-label">{label}</p>
					<div class="slc-controls">
						{cycles && autoplay && showPauseControl && (
							<button
								class="slc-toggle"
								type="button"
								data-slc-toggle
								aria-controls={stageId}
								aria-pressed="false"
								hidden
							>
								<span class="slc-sr" data-slc-toggle-label>
									{pauseLabel}
								</span>
								<span class="slc-glyph slc-glyph-pause" aria-hidden="true" />
								<span class="slc-glyph slc-glyph-play" aria-hidden="true" />
							</button>
						)}
					</div>
				</div>

				{/*
				    Every group is a layer in one grid cell of this stage, so the
				    stage is as tall as a group and stays that height while two of
				    them overlap.
				*/}
				<div class="slc-stage" id={stageId} data-slc-stage>
					{resolvedGroups.map((group, groupIndex) => (
						<ul
							class="slc-grid"
							data-slc-group
							data-slc-group-index={String(groupIndex)}
							data-slc-state={groupIndex === 0 ? "active" : "idle"}
							aria-hidden={groupIndex === 0 ? undefined : "true"}
						>
							{Array.from({ length: cellCount }).map((_, cellIndex) => {
								const logo = group.logos[cellIndex];
								if (!logo) {
									/* A padding cell. It holds the column open so a short
									   group cannot change the geometry when its turn comes. */
									return <li class="slc-cell" style={`--slc-i:${cellIndex}`} aria-hidden="true" />;
								}
								/* One wrapper, whose tag depends on whether the item ended up
								   with a usable href. Rendering the media twice — once inside
								   a link and once outside it — is how the two copies drift. */
								const Item = logo.href ? "a" : "span";
								return (
									<li class="slc-cell" style={`--slc-i:${cellIndex}`}>
										<span class="slc-logo">
											<Item
												class="slc-item"
												href={logo.href}
												target={logo.external ? "_blank" : undefined}
												rel={logo.external ? "noopener noreferrer" : undefined}
												tabindex={logo.href && groupIndex !== 0 ? "-1" : undefined}
											>
												{logo.src ? (
													<img
														class:list={["slc-img", logo.invert && "is-invertible"]}
														src={logo.src}
														alt={logo.alt}
														width={logo.width}
														height={logo.height}
														loading="lazy"
														decoding="async"
														draggable="false"
													/>
												) : (
													<span class:list={["slc-mark", `is-${logo.wordmark}`]}>{logo.name}</span>
												)}
											</Item>
										</span>
									</li>
								);
							})}
						</ul>
					))}
				</div>
			</div>
		</section>
	)
}

<style>
	.slc {
		/* ---- overridable tokens -------------------------------------------
		   Set any of these on the element, from a class, an id or a wrapper
		   rule, to retune the component without touching this file. */
		--slc-max-width: 80rem;
		--slc-padding-inline: 16px;
		--slc-margin-top: 48px;
		--slc-padding-bottom: 24px;
		--slc-label-gap: 24px;
		--slc-logo-height: 20px;
		--slc-cell-height: 40px;
		--slc-row-gap: 48px;
		--slc-column-gap: 16px;

		--slc-duration: 560ms;
		--slc-stagger: 45ms;
		--slc-ease: cubic-bezier(0.16, 1, 0.3, 1);
		--slc-blur: 6px;
		--slc-travel: 32px;
		--slc-exit-scale: 0.5;

		--slc-surface: transparent;
		--slc-label-color: #6b7280;
		--slc-logo-color: #52525b;
		--slc-ring: #6366f1;
		--slc-font: inherit;

		box-sizing: border-box;
		display: block;
		background: var(--slc-surface);
		font-family: var(--slc-font);
		/* A host page with `line-height: 1` would otherwise collapse the label. */
		line-height: 1.5;
	}

	/* `:where` keeps specificity at zero, so a destination project's own rules
	   still win where it means them to. */
	.slc :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.slc-inner {
		max-width: var(--slc-max-width);
		margin-inline: auto;
		padding-inline: var(--slc-padding-inline);
		padding-bottom: var(--slc-padding-bottom);
	}

	.slc-sr {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
		border: 0;
	}

	/* ---------- label and control ---------- */

	.slc-head {
		display: grid;
		/* Equal end columns keep the label centred on the section, not on the
		   space left over beside the button. */
		grid-template-columns: 44px 1fr 44px;
		align-items: center;
		gap: 8px;
		min-height: 44px;
	}

	.slc-label {
		margin: 0;
		text-align: center;
		color: var(--slc-label-color);
		font-size: 0.875rem;
	}

	.slc-controls {
		display: flex;
		justify-content: flex-end;
	}

	/*
	 * 44x44 minimum target, with the visible glyph smaller inside it. The
	 * reference has no control at all; see the notes in the README for why one
	 * is added here rather than leaving a timer the visitor cannot stop.
	 */
	.slc-toggle {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: 44px;
		height: 44px;
		padding: 0;
		border: 0;
		border-radius: 999px;
		background: transparent;
		color: var(--slc-label-color);
		cursor: pointer;
	}

	.slc-toggle:hover {
		color: var(--slc-logo-color);
	}

	.slc-toggle:focus-visible {
		outline: 2px solid var(--slc-ring);
		outline-offset: 2px;
	}

	.slc-glyph {
		width: 10px;
		height: 12px;
		background: currentColor;
	}

	.slc-glyph-pause {
		/* Two bars, drawn with a clip rather than two elements. */
		clip-path: polygon(0 0, 35% 0, 35% 100%, 0 100%, 0 0, 65% 0, 100% 0, 100% 100%, 65% 100%, 65% 0);
	}

	.slc-glyph-play {
		clip-path: polygon(0 0, 100% 50%, 0 100%);
		display: none;
	}

	/* Pressed means "rotation is paused", so the control offers play. */
	.slc-toggle[aria-pressed="true"] .slc-glyph-pause {
		display: none;
	}

	.slc-toggle[aria-pressed="true"] .slc-glyph-play {
		display: block;
	}

	/* ---------- stage ---------- */

	.slc-stage {
		display: grid;
		margin-top: var(--slc-label-gap);
	}

	/*
	 * Every group occupies the same single grid cell. That is what makes the
	 * overlap during a transition free: there is no positioning to compute and
	 * no second layout to keep in step.
	 */
	.slc-grid {
		grid-area: 1 / 1;
		display: grid;
		grid-template-columns: repeat(3, minmax(0, 1fr));
		gap: var(--slc-row-gap) var(--slc-column-gap);
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.slc-cell {
		display: flex;
		align-items: center;
		justify-content: center;
		/* Fixed, so the stage height cannot depend on which logos are in it. */
		height: var(--slc-cell-height);
		min-width: 0;
	}

	.slc-logo {
		display: flex;
		align-items: center;
		justify-content: center;
		max-width: 100%;
		/* The animated element. Grayscale lives on the image inside it, so a
		   blur transition cannot drop it. */
		transition:
			opacity var(--slc-duration) var(--slc-ease),
			filter var(--slc-duration) var(--slc-ease),
			transform var(--slc-duration) var(--slc-ease);
		transition-delay: calc(var(--slc-i, 0) * var(--slc-stagger));
		will-change: opacity, transform, filter;
	}

	.slc-item {
		display: flex;
		align-items: center;
		justify-content: center;
		max-width: 100%;
		color: inherit;
		text-decoration: none;
		border-radius: 4px;
	}

	.slc-item:focus-visible {
		outline: 2px solid var(--slc-ring);
		outline-offset: 4px;
	}

	.slc-img {
		display: block;
		/* Height is the constant; width follows the intrinsic ratio. */
		height: var(--slc-logo-height);
		width: auto;
		max-width: 100%;
		object-fit: contain;
		filter: grayscale(1);
		opacity: 0.75;
	}

	/* ---------- text wordmarks ----------
	   What renders when an item has no `src`. Enough variation to judge spacing
	   and motion without shipping anybody's artwork. */

	.slc-mark {
		display: inline-flex;
		align-items: center;
		gap: 0.35em;
		max-width: 100%;
		color: var(--slc-logo-color);
		font-size: calc(var(--slc-logo-height) * 0.8);
		line-height: 1;
		white-space: nowrap;
		overflow: hidden;
		text-overflow: ellipsis;
	}

	.slc-mark.is-plain {
		font-weight: 500;
	}

	.slc-mark.is-bold {
		font-weight: 800;
		letter-spacing: -0.02em;
	}

	.slc-mark.is-wide {
		font-weight: 500;
		letter-spacing: 0.22em;
		text-transform: uppercase;
		font-size: calc(var(--slc-logo-height) * 0.62);
	}

	.slc-mark.is-dot::before {
		content: "";
		flex: none;
		width: 0.5em;
		height: 0.5em;
		border-radius: 999px;
		background: currentColor;
	}

	.slc-mark.is-slash::before {
		content: "";
		flex: none;
		width: 0.16em;
		height: 1em;
		background: currentColor;
		transform: skewX(-18deg);
	}

	.slc-mark.is-box {
		font-weight: 700;
		text-transform: uppercase;
		font-size: calc(var(--slc-logo-height) * 0.6);
		letter-spacing: 0.06em;
	}

	.slc-mark.is-box::before {
		content: "";
		flex: none;
		width: 0.85em;
		height: 0.85em;
		border: 0.16em solid currentColor;
		border-radius: 0.2em;
	}

	/* ---------- group states ----------
	   Four states, and the whole animation is the transition between them. */

	/* Off stage. `visibility: hidden` also takes its links out of the tab
	   order, which is belt to the `tabindex="-1"` braces the controller sets. */
	.slc-grid[data-slc-state="idle"] {
		visibility: hidden;
	}

	.slc-grid[data-slc-state="idle"] .slc-logo {
		opacity: 0;
	}

	/* The starting pose of an arriving group: left of centre, blurred, clear. */
	.slc-grid[data-slc-state="enter"] .slc-logo {
		opacity: 0;
		filter: blur(var(--slc-blur));
		transform: translateX(calc(var(--slc-travel) * -1)) scale(1);
	}

	.slc-grid[data-slc-state="active"] .slc-logo {
		opacity: 1;
		filter: blur(0);
		transform: translateX(0) scale(1);
	}

	/* The finishing pose of a departing group: right of centre, blurred, small. */
	.slc-grid[data-slc-state="exit"] .slc-logo {
		opacity: 0;
		filter: blur(var(--slc-blur));
		transform: translateX(var(--slc-travel)) scale(var(--slc-exit-scale));
	}

	/* Used for one frame, to place a group in its starting pose without
	   animating it there. */
	.slc[data-slc-instant] .slc-logo {
		transition: none !important;
	}

	/* ---------- responsive ----------
	   Three columns, then four, then one row of however many cells the groups
	   normalised to — eight, for a set of eight. */

	@media (min-width: 640px) {
		.slc-grid {
			grid-template-columns: repeat(4, minmax(0, 1fr));
		}
	}

	@media (min-width: 1024px) {
		.slc-inner {
			margin-top: var(--slc-margin-top);
		}

		.slc-grid {
			grid-template-columns: repeat(var(--slc-cols, 8), minmax(0, 1fr));
			/* One row, so the row gap has nothing to act on. */
			gap: 0 var(--slc-column-gap);
		}
	}

	/* ---------- reduced motion ----------
	   The controller refuses to run a timer at all under this preference, so
	   there is nothing to animate; this makes any state change instant in case
	   one is triggered before the controller reads the preference. */
	@media (prefers-reduced-motion: reduce) {
		.slc-logo {
			transition-duration: 1ms;
			transition-delay: 0ms;
		}
	}
</style>

<script>
	/**
	 * StaggeredLogoCloud controller.
	 *
	 * One closure per instance. Every query is scoped to that instance's root,
	 * every listener carries an AbortController signal, and the whole thing is
	 * torn down before a client-side navigation swaps the document.
	 */
	interface Instance {
		destroy(): void;
	}

	function initStaggeredLogoCloud(root: HTMLElement): Instance | undefined {
		/* Idempotent: booting twice is a no-op, not a second set of listeners. */
		if (root.dataset.slcReady === "") return undefined;

		const stage = root.querySelector<HTMLElement>("[data-slc-stage]");
		const groups = [...root.querySelectorAll<HTMLElement>("[data-slc-group]")];
		if (!stage || groups.length === 0) return undefined;

		root.dataset.slcReady = "";

		const controller = new AbortController();
		const { signal } = controller;

		const cycles = root.dataset.slcCycles === "1" && groups.length > 1;
		const wantsAutoplay = root.dataset.slcAutoplay === "1" && cycles;
		const pauseOnHover = root.dataset.slcPauseHover === "1";
		const interval = Number(root.dataset.slcInterval) || 4000;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");

		const toggle = root.querySelector<HTMLButtonElement>("[data-slc-toggle]");
		const toggleLabel = root.querySelector<HTMLElement>("[data-slc-toggle-label]");
		const pauseLabel = root.dataset.slcPauseLabel || "Pause logo rotation";
		const playLabel = root.dataset.slcPlayLabel || "Resume logo rotation";

		let index = 0;
		let transitioning = false;
		let timer: number | undefined;
		let settleTimer: number | undefined;

		let hovering = false;
		let focused = false;
		let manuallyPaused = false;

		/* How long the last logo is still moving: the shared duration plus the
		   delay the final cell waits before it starts. */
		function settleDuration(): number {
			const styles = getComputedStyle(root);
			const duration = Number.parseFloat(styles.getPropertyValue("--slc-duration")) || 560;
			const stagger = Number.parseFloat(styles.getPropertyValue("--slc-stagger")) || 45;
			const cells = groups[0]?.querySelectorAll("[data-slc-group] > *").length ?? 8;
			return duration + stagger * Math.max(cells - 1, 0) + 60;
		}

		function setInstant(on: boolean) {
			root.toggleAttribute("data-slc-instant", on);
			/* Flush, so the next attribute change animates from here. */
			if (on) void root.offsetWidth;
		}

		/**
		 * Expose exactly one group.
		 *
		 * `aria-hidden` and `tabindex` move together: a group that is not the
		 * active one must be neither announced nor tabbable, or a four-group
		 * cloud offers every link four times.
		 */
		function expose(group: HTMLElement, exposed: boolean) {
			if (exposed) group.removeAttribute("aria-hidden");
			else group.setAttribute("aria-hidden", "true");
			group.querySelectorAll<HTMLElement>("a[href]").forEach((link) => {
				if (exposed) link.removeAttribute("tabindex");
				else link.setAttribute("tabindex", "-1");
			});
		}

		function goTo(next: number) {
			/* A tick must never start a second transition over a running one. */
			if (transitioning || !cycles) return;
			const target = ((next % groups.length) + groups.length) % groups.length;
			if (target === index) return;

			const outgoing = groups[index];
			const incoming = groups[target];
			transitioning = true;

			/* Place the arriving group in its starting pose without animating
			   it there, then let it animate to rest on the next frame. */
			setInstant(true);
			incoming.dataset.slcState = "enter";
			expose(incoming, true);
			expose(outgoing, false);
			setInstant(false);

			requestAnimationFrame(() => {
				incoming.dataset.slcState = "active";
				outgoing.dataset.slcState = "exit";
			});

			index = target;

			window.clearTimeout(settleTimer);
			settleTimer = window.setTimeout(() => {
				/* The departed group goes off stage with no animation: it is
				   already invisible, so returning it to rest is free. */
				setInstant(true);
				outgoing.dataset.slcState = "idle";
				setInstant(false);
				transitioning = false;
			}, settleDuration());
		}

		/* ---- autoplay ---- */

		/*
		 * Reduced motion is decisive, not advisory: the timer never runs under
		 * it, and pressing play cannot start one. A preference the visitor set
		 * is not something a control on the page gets to override.
		 */
		const autoplayAllowed = () => wantsAutoplay && !reduced.matches;

		function canRun(): boolean {
			if (!autoplayAllowed()) return false;
			if (manuallyPaused) return false;
			if (document.hidden) return false;
			if (pauseOnHover && hovering) return false;
			if (focused) return false;
			return true;
		}

		function stopAuto() {
			if (timer !== undefined) window.clearInterval(timer);
			timer = undefined;
		}

		function startAuto() {
			stopAuto();
			if (!canRun()) return;
			timer = window.setInterval(() => goTo(index + 1), interval);
		}

		const refreshAuto = () => (canRun() ? startAuto() : stopAuto());

		function syncToggle() {
			if (!toggle) return;
			const paused = manuallyPaused;
			toggle.setAttribute("aria-pressed", paused ? "true" : "false");
			if (toggleLabel) toggleLabel.textContent = paused ? playLabel : pauseLabel;
		}

		if (autoplayAllowed() && toggle) {
			/* Only revealed once there is a controller behind it. A control that
			   cannot do anything is worse than no control. */
			toggle.hidden = false;
			syncToggle();
			toggle.addEventListener(
				"click",
				() => {
					manuallyPaused = !manuallyPaused;
					syncToggle();
					refreshAuto();
				},
				{ signal },
			);
		}

		if (autoplayAllowed()) {
			if (pauseOnHover) {
				root.addEventListener("pointerenter", () => { hovering = true; refreshAuto(); }, { signal });
				root.addEventListener("pointerleave", () => { hovering = false; refreshAuto(); }, { signal });
			}
			root.addEventListener("focusin", () => { focused = true; refreshAuto(); }, { signal });
			root.addEventListener(
				"focusout",
				(event) => {
					if (!root.contains(event.relatedTarget as Node)) {
						focused = false;
						refreshAuto();
					}
				},
				{ signal },
			);
			document.addEventListener("visibilitychange", refreshAuto, { signal });
			reduced.addEventListener("change", refreshAuto, { signal });
		}

		/* Initial exposure, in case the markup and the controller disagree. */
		groups.forEach((group, i) => expose(group, i === index));

		refreshAuto();

		function destroy() {
			stopAuto();
			window.clearTimeout(settleTimer);
			groups.forEach((group, i) => {
				group.dataset.slcState = i === 0 ? "active" : "idle";
				expose(group, i === 0);
			});
			root.removeAttribute("data-slc-instant");
			controller.abort();
			delete root.dataset.slcReady;
		}

		/*
		 * Astro replaces the document on a client-side navigation. Stop the
		 * timer, return every group to the state it was served in, detach every
		 * listener, and clear the ready flag so a re-used element can boot again.
		 */
		document.addEventListener("astro:before-swap", destroy, { once: true, signal });

		return { destroy };
	}

	const boot = () =>
		document.querySelectorAll<HTMLElement>("[data-slc-root]").forEach(initStaggeredLogoCloud);

	/* Astro's own <script> is a deferred module, so the DOM is already parsed
	   by the time this runs. The readyState check is for the case where this
	   file is copied into a project that inlines it some other way. */
	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	/* Fires on every view transition in a project using <ClientRouter />, and
	   never at all in one that does not. Registered once per module, so it
	   cannot accumulate. */
	document.addEventListener("astro:page-load", boot);
</script>

===== END src/components/StaggeredLogoCloud.astro =====

===== FILE (required): src/components/IconFeatureGrid.astro =====
---
/**
 * IconFeatureGrid — a short editorial lead-in above a dense grid of
 * icon-topped feature cards that trade colours with their icon tile under
 * the pointer.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * no webfonts, no images, no network requests of any kind: drop it into any
 * Astro project and render it.
 *
 * Two things carry the design. The first is the gap: the icon tile sits at the
 * top of the card and the title starts 48px below it, so every card has a band
 * of empty colour in the middle that the tile appears to float in. The second
 * is the hover trade — the card is tinted and the tile is white at rest, and on
 * hover they swap, so the tile reads as a hole punched through the card rather
 * than a badge sitting on it. The tile tips and lifts at the same moment.
 *
 * Measured behaviour (reproduced from a 1440px reference render):
 *   shell        1024px max width, 48px / 16px / 16px padding, dashed rails
 *   columns      1 -> 2 at >=768px -> 4 at >=1024px (never 3)
 *   grid         8px gap, 32px above, equal-height rows
 *   card         16px padding, 12px radius, tinted ground
 *   tile         52px square, 8px radius, 20px glyph, 2px strokes
 *   rhythm       tile -> 48px -> 14px title -> 8px -> 14px body
 *   hover        card and tile swap grounds; tile rotates -12deg, rises 8px
 *   timing       300ms ease on every hover property, no delay, no stagger
 *   shadow       0 25px 50px -12px rgba(0, 0, 0, 0.25) on the hovered card
 *
 * The section is otherwise static: no scripts, no scroll effects, no entrance
 * animation, nothing that runs on a timer.
 *
 * Several instances can share a page. The only id the component mints is the
 * one joining the section to its heading, and it is derived from an instance
 * uid rather than written into the markup as a constant.
 *
 * Every element here is written by this file. Nothing a caller passes is
 * rendered as markup: a custom glyph arrives as SVG path `d` strings, which
 * become attributes of paths the component writes, so item data from a CMS or
 * an API cannot introduce an element, a handler or a request.
 */

/** A glyph drawn by this component, named for its shape rather than a meaning. */
export type FeatureIcon =
	| "orbit"
	| "pulse"
	| "layers"
	| "exchange"
	| "target"
	| "brackets"
	| "frame"
	| "gauge"
	| "spark";

export interface FeatureItem {
	/** Card title. The only required field. */
	title: string;
	/** Supporting paragraph under the title. Optional. */
	body?: string;
	/** Which built-in glyph to draw. Unknown or missing falls back to "spark". */
	icon?: FeatureIcon;
	/**
	 * SVG path `d` values for a glyph this component does not ship — one
	 * string per `<path>`, drawn on the same 24x24 grid as the built-ins.
	 *
	 * Path data only, never markup: a complete `<svg>` string, an `<image>`,
	 * an event attribute or anything else is not parsed, not unwrapped and not
	 * rendered. Each string becomes the `d` attribute of a `<path>` the
	 * component writes itself, so a value that arrives from a CMS or an API
	 * can draw a wrong shape but cannot introduce an element or a handler.
	 *
	 * Used only when `icon` names no built-in. Empty, blank or absent falls
	 * back to "spark".
	 */
	iconPaths?: string[];
}

interface Props {
	/** Section heading. Empty string hides it. */
	heading?: string;
	/** Paragraph under the heading. Empty string hides it. */
	intro?: string;
	/** Heading level, for pages where h2 is the wrong rank. */
	headingLevel?: "h1" | "h2" | "h3";
	/** The cards. Any count works; the grid fills rows left to right. */
	items?: FeatureItem[];
	/** Columns from 1024px up. Below that it is always 2, then 1 under 768px. */
	columns?: 2 | 3 | 4;
	/** Page ground the section paints behind itself. */
	surface?: string;
	/** Card ground at rest. Becomes the tile's ground on hover. */
	cardSurface?: string;
	/** Tile ground at rest. Becomes the card's ground on hover. */
	tileSurface?: string;
	/** Glyph colour. */
	accent?: string;
	/** Title colour. The muted body tone is derived from it. */
	ink?: string;
	/**
	 * Dashed rails down the shell's edges and across its top, which line the
	 * section up with the ones above and below it. Off for a section that
	 * stands alone.
	 */
	rules?: boolean;
	/** Shell max width. The reference sits at 64rem. */
	maxWidth?: string;
	/** Body font stack. */
	font?: string;
	/** Heading font stack. Serif in the reference, against a sans body. */
	headingFont?: string;
	class?: string;
	id?: string;
}

/* Annotated rather than inferred. Dropping the annotation checks clean today
   -- measured, not assumed -- but during this component's development the
   checker's `Props`-to-`Astro.props` wiring proved sensitive to where things
   sit in the frontmatter, and it fails by silently typing every prop `any`
   rather than by complaining. One annotation is cheaper than that. */
const {
	heading = "",
	intro = "",
	headingLevel = "h2",
	items = [],
	columns = 4,
	surface = "#ffffff",
	cardSurface = "#f7f5f0",
	tileSurface = "#ffffff",
	accent = "#e06040",
	ink = "#171717",
	rules = true,
	maxWidth = "64rem",
	font = "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
	headingFont = "ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif",
	class: className,
	id,
}: Props = Astro.props;

const Heading = headingLevel;

/* No demo content: the cards and the lead-in are props. A card with no title
   is dropped; with no cards left nothing renders, and a heading alone never
   makes the section render. */
const cards = (items ?? []).filter((item) => item?.title);

/* Random rather than sequential: a module-level counter is shared by every
   render in the same server process, which is fine for one page and wrong the
   moment two pages are built in parallel and compared. */
const uid = id ?? `ifg-${Math.random().toString(36).slice(2, 9)}`;
const headingId = `${uid}-heading`;

/*
 * The glyphs. Drawn here as plain path data on a 24x24 grid with 2px strokes
 * and round joins — abstract shapes, not a traced icon set, so the component
 * carries no font and no sprite file and nothing to download.
 */
const glyphs: Record<FeatureIcon, string[]> = {
	orbit: ["M12 3.6a8.4 8.4 0 1 0 0 16.8 8.4 8.4 0 0 0 0-16.8Z", "M17.2 5.6a2.3 2.3 0 1 0 0 4.6 2.3 2.3 0 0 0 0-4.6Z"],
	pulse: ["M2.5 14h3.8l2.6-7.5L13 18l2.3-4h6.2"],
	layers: ["M12 3.2 3.4 7.4 12 11.6l8.6-4.2-8.6-4.2Z", "M3.4 12.2 12 16.4l8.6-4.2", "M3.4 16.8 12 21l8.6-4.2"],
	exchange: ["M7.5 20.5V3.5", "M3.8 7.2 7.5 3.5l3.7 3.7", "M16.5 3.5v17", "M20.2 16.8l-3.7 3.7-3.7-3.7"],
	target: ["M12 3.6a8.4 8.4 0 1 0 0 16.8 8.4 8.4 0 0 0 0-16.8Z", "M12 8.6a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8Z", "M12 1.5v3.2M12 19.3v3.2M1.5 12h3.2M19.3 12h3.2"],
	brackets: ["M8.8 5.6 3.4 12l5.4 6.4", "M15.2 5.6 20.6 12l-5.4 6.4", "M13.4 3.8 10.6 20.2"],
	frame: ["M4 4.2h16a.8.8 0 0 1 .8.8v14a.8.8 0 0 1-.8.8H4a.8.8 0 0 1-.8-.8V5a.8.8 0 0 1 .8-.8Z", "M3.2 9.4h17.6", "M9.4 9.4v10.4"],
	gauge: ["M3.4 17.4a8.6 8.6 0 1 1 17.2 0", "M12 17.4l4.4-5.6", "M6.6 13.2h.01M12 8.6h.01M17.4 13.2h.01"],
	spark: ["M12 2.8v5.4M12 15.8v5.4M2.8 12h5.4M15.8 12h5.4", "M12 8.6a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8Z"],
};

/*
 * Which paths a card draws. A built-in name wins; failing that, a caller's own
 * path data; failing that, the fallback glyph — an unknown name is a typo, not
 * a reason to leave a hole in the card.
 *
 * `Array.isArray` rather than a plain lookup, because an `icon` value that
 * arrived as data rather than as a literal could name something inherited from
 * Object.prototype, and that must read as "not a built-in" rather than as a
 * function to map over.
 */
const pathsFor = (item: FeatureItem): string[] => {
	const named = item.icon ? glyphs[item.icon] : undefined;
	if (Array.isArray(named)) return named;

	/* Blank entries are dropped rather than emitted as empty paths, so a list
	   of nothing but whitespace falls back instead of drawing an empty tile. */
	const custom = (item.iconPaths ?? []).filter(
		(d): d is string => typeof d === "string" && d.trim() !== "",
	);
	return custom.length > 0 ? custom : glyphs.spark;
};

const styleVars = [
	`--ifg-surface:${surface}`,
	`--ifg-card:${cardSurface}`,
	`--ifg-tile:${tileSurface}`,
	`--ifg-accent:${accent}`,
	`--ifg-ink:${ink}`,
	`--ifg-maxw:${maxWidth}`,
	`--ifg-font:${font}`,
	`--ifg-heading-font:${headingFont}`,
	`--ifg-cols:${columns}`,
].join(";");
---

{cards.length > 0 && (
<section
	class:list={["feature-grid", className]}
	id={id}
	aria-labelledby={heading ? headingId : undefined}
	data-rules={rules ? "" : undefined}
	style={styleVars}
>
	<div class="shell">
		{
			(heading || intro) && (
				<div class="intro">
					{heading && (
						<Heading class="heading" id={headingId}>
							{heading}
						</Heading>
					)}
					{intro && <p class="lead">{intro}</p>}
				</div>
			)
		}

		<ul class="grid" role="list">
			{
				cards.map((item) => (
					<li class="card">
						<span class="tile" aria-hidden="true">
							<svg
								class="glyph"
								viewBox="0 0 24 24"
								fill="none"
								stroke="currentColor"
								stroke-width="2"
								stroke-linecap="round"
								stroke-linejoin="round"
								focusable="false"
							>
								{pathsFor(item).map((d) => (
									<path d={d} />
								))}
							</svg>
						</span>
						<h3 class="card-title">{item.title}</h3>
						{item.body && <p class="card-body">{item.body}</p>}
					</li>
				))
			}
		</ul>
	</div>
</section>
)}

<style>
	.feature-grid {
		/* Derived from --ifg-ink so one override re-tints the body copy with
		   the titles instead of leaving it behind at the old hue. */
		--ifg-muted: color-mix(in srgb, var(--ifg-ink) 58%, transparent);
		--ifg-rule: color-mix(in srgb, var(--ifg-ink) 12%, transparent);

		--ifg-gap: 8px;
		--ifg-pad: 16px;
		--ifg-radius: 12px;
		--ifg-duration: 300ms;

		box-sizing: border-box;
		display: block;
		background: var(--ifg-surface);
		color: var(--ifg-ink);
		font-family: var(--ifg-font);
		/* A host page with its own `line-height: 1` would otherwise collapse
		   every measurement below. */
		line-height: 1.5;
	}

	/* `:where` keeps specificity at zero, so a destination project's own rules
	   still win where it means them to. */
	.feature-grid :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.shell {
		max-width: var(--ifg-maxw);
		margin-inline: auto;
		padding: 48px 16px 16px;
	}

	/* The rails belong to the page rhythm, not to the card grid: they run down
	   the shell's edges so a stack of sections reads as one ruled column. */
	.feature-grid[data-rules] .shell {
		border-top: 1px dashed var(--ifg-rule);
		border-left: 1px dashed var(--ifg-rule);
		border-right: 1px dashed var(--ifg-rule);
	}

	/* ---------- lead-in ---------- */

	.intro {
		max-width: 36rem;
		text-wrap: balance;
	}

	.heading {
		margin: 0;
		font-family: var(--ifg-heading-font);
		font-size: 1.125rem;
		line-height: 1.75rem;
		font-weight: 500;
		letter-spacing: -0.025em;
		color: var(--ifg-ink);
	}

	.lead {
		margin: 1rem 0 0;
		font-size: 1rem;
		line-height: 1.5rem;
		color: var(--ifg-muted);
	}

	/* ---------- grid ---------- */

	.grid {
		display: grid;
		grid-template-columns: 1fr;
		gap: var(--ifg-gap);
		margin: 2rem 0 0;
		padding: 0;
		list-style: none;
	}

	.card {
		display: flex;
		flex-direction: column;
		/* Rows are sized by their tallest card and every card fills its row, so
		   the tinted blocks line up even when one title wraps to two lines. */
		height: 100%;
		padding: var(--ifg-pad);
		border-radius: var(--ifg-radius);
		background: var(--ifg-card);
		/* Drawn at rest in the resting colour so the hovered state changes the
		   outline's colour rather than adding a box, which would shift layout. */
		outline: 1px solid transparent;
		transition:
			background-color var(--ifg-duration) ease,
			box-shadow var(--ifg-duration) ease,
			outline-color var(--ifg-duration) ease;
	}

	/* `focus-within` is not in the reference. It is here so that a project which
	   puts a link inside a card gets the same state from the keyboard that a
	   mouse gets, rather than a card that never responds to tabbing. */
	.card:hover,
	.card:focus-within {
		background: var(--ifg-tile);
		outline-color: var(--ifg-card);
		box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
	}

	.tile {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: fit-content;
		padding: var(--ifg-pad);
		border-radius: 8px;
		background: var(--ifg-tile);
		color: var(--ifg-accent);
		transition:
			background-color var(--ifg-duration) ease,
			rotate var(--ifg-duration) ease,
			translate var(--ifg-duration) ease;
	}

	.card:hover .tile,
	.card:focus-within .tile {
		background: var(--ifg-card);
		rotate: -12deg;
		translate: 0 -8px;
	}

	/* Scoped to the component's own element: every glyph is written by this
	   file, whether its paths are built in or supplied, so the rule needs no
	   `:global` escape hatch to reach it. */
	.glyph {
		display: block;
		width: 20px;
		height: 20px;
		/* A host stylesheet that fills every svg would otherwise flood the
		   outline shapes. */
		fill: none;
		stroke: currentColor;
	}

	.card-title {
		/* The band of empty card between tile and title is the whole look. */
		margin: 3rem 0 0;
		font-size: 0.875rem;
		line-height: 1.25rem;
		font-weight: 500;
		color: var(--ifg-ink);
	}

	.card-body {
		margin: 0.5rem 0 0;
		font-size: 0.875rem;
		line-height: 1.25rem;
		color: var(--ifg-muted);
	}

	/* ---------- measured breakpoints ----------
	   640 / 768 / 1024 / 1536, probed either side on the reference. The grid
	   goes 1 -> 2 -> N and never stops at 3. */

	@media (min-width: 640px) {
		.heading {
			font-size: 1.25rem;
			line-height: 1.75rem;
		}
	}

	@media (min-width: 768px) {
		.heading {
			font-size: 1.5rem;
			line-height: 2rem;
		}

		.grid {
			grid-template-columns: repeat(2, minmax(0, 1fr));
		}
	}

	@media (min-width: 1024px) {
		.heading {
			font-size: 1.875rem;
			line-height: 2.25rem;
		}

		.grid {
			grid-template-columns: repeat(var(--ifg-cols), minmax(0, 1fr));
		}
	}

	/* The reference widens its shell and its gutters together at 1536px. */
	@media (min-width: 1536px) {
		.shell {
			max-width: calc(var(--ifg-maxw) + 8rem);
			padding-inline: 48px;
		}
	}

	/* The reference animates regardless of the setting. Honouring it is a
	   deliberate change: the states still swap, they just arrive at once. */
	@media (prefers-reduced-motion: reduce) {
		.card,
		.tile {
			transition-duration: 1ms;
		}

		.card:hover .tile,
		.card:focus-within .tile {
			rotate: none;
			translate: none;
		}
	}
</style>

===== END src/components/IconFeatureGrid.astro =====

===== FILE (required): src/components/ProcessFlowGrid.astro =====
---
/**
 * ProcessFlowGrid — a sequence of steps laid out as a grid that reads back and
 * forth, with arrows drawn through the gaps from each step to the next.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * no webfonts, no images and no network requests of its own.
 *
 * The decisions that carry the weight, and why they are shaped this way:
 *
 *   - **It is an ordered list, and it says so.** The steps are `<li>` inside an
 *     `<ol>`, so the order is in the markup rather than only in the arrows. The
 *     arrows are decorative and marked as such: they draw the sequence for
 *     someone who can see it, and the list carries it for everyone else.
 *
 *   - **The serpentine is layout, not order.** Reading runs left to right along
 *     the first row and right to left along the next, so the eye never jumps
 *     back across the grid. That reversal is done by assigning each card a
 *     grid column — the document order is always 1, 2, 3, 4.
 *
 *   - **Each arrow belongs to the card it leaves.** The reference draws all of
 *     them in one absolutely-positioned SVG over the grid, with a fixed
 *     `viewBox` and `preserveAspectRatio="none"`. That works while the cards
 *     are exactly the height it was drawn for, and stretches the arrows the
 *     moment somebody writes a longer step. Here each arrow is a small SVG
 *     anchored to its own card's edge and sized to the gap, so it lands in the
 *     right place whatever the content does to the row heights.
 *
 *   - **Stacked, every arrow points down.** One column makes a back-and-forth
 *     meaningless, so the same connectors turn into a single vertical run. The
 *     shape swap is CSS; nothing is re-rendered.
 *
 * Several instances can share a page: there is no script, no state and no id
 * collision surface. It is CSS and markup.
 */

export interface ProcessFlowStep {
	/** The step's name. */
	title: string;
	/** What happens in it. */
	body?: string;
}

export interface ProcessFlowGridProps {
	/** The steps, in order. */
	steps?: ProcessFlowStep[];
	/**
	 * How many steps are done. Arrows leaving a completed step are drawn in
	 * full; the rest are drawn in the same colour held back, which is how the
	 * reference distinguishes them. Defaults to every step.
	 */
	completed?: number;
	/** Accessible name for the list. */
	label?: string;
	/** Optional extra class for project-specific layout. */
	class?: string;
	/** Element id. */
	id?: string;
}

interface Props extends ProcessFlowGridProps {}

const {
	steps,
	completed,
	label = "Process",
	class: className,
	id,
}: Props = Astro.props;

const text = (value: unknown) => (typeof value === "string" ? value : "");

const list = (Array.isArray(steps) ? steps : []).filter(
	(step) => step && text(step.title).trim() !== "",
);
/* No steps, nothing rendered: an empty grid, or a set of stand-in steps,
   is not a process. */
const resolved = list;

/*
 * Two columns is the shape this is: a back-and-forth needs exactly two, and a
 * third would make the return leg cross the grid rather than step back one.
 * It is a constant rather than a prop for that reason.
 */
const COLUMNS = 2;

/**
 * Where each card sits, and which way it hands over to the next.
 *
 * Both fall out of the index. An even row runs left to right and an odd row
 * runs right to left, so the column for an odd row is counted from the other
 * end; the arrow is horizontal when the next card shares the row and vertical
 * when it starts a new one.
 */
const cards = resolved.map((step, index) => {
	const row = Math.floor(index / COLUMNS);
	const place = index % COLUMNS;
	const rightward = row % 2 === 0;
	const last = index === resolved.length - 1;
	const endsRow = place === COLUMNS - 1;

	return {
		title: text(step.title),
		body: text(step.body),
		/* 1-based, and mirrored on every other row. */
		column: rightward ? place + 1 : COLUMNS - place,
		/*
		 * The row is explicit too, and it has to be.
		 *
		 * Auto-placement only ever moves forwards: once the cursor has passed
		 * column 1 of a row it will not come back for it, so a card sent to
		 * column 1 on a reversed row lands on a NEW row instead and the grid
		 * grows a row per pair. Pinning both axes is what makes the
		 * back-and-forth a layout rather than a staircase.
		 */
		row: row + 1,
		direction: last ? "none" : endsRow ? "down" : rightward ? "right" : "left",
		/* An arrow is "live" when the step it leaves is done. */
		live: typeof completed === "number" ? index < completed : true,
	};
});
---

{cards.length > 0 && (
<div class:list={["process-flow-grid", className]} id={id}>
	<ol class="pfg-list" aria-label={label}>
		{cards.map((card) => (
			<li
				class="pfg-card"
				style={`--pfg-column: ${card.column}; --pfg-row: ${card.row};`}
				data-pfg-direction={card.direction}
				data-pfg-live={card.live ? "" : undefined}
			>
				<h3 class="pfg-title">{card.title}</h3>
				{card.body !== "" && <p class="pfg-body">{card.body}</p>}

				{/*
				    Decorative. The order it illustrates is already in the <ol>,
				    so announcing it again would be telling the same thing twice
				    in a worse way.
				*/}
				{card.direction !== "none" && (
					<span class="pfg-link" aria-hidden="true">
						{/* Sized to the gap, so the stroke keeps its weight while the
						    box is stretched to fit. */}
						<svg class="pfg-arrow pfg-arrow--across" viewBox="0 0 48 12" preserveAspectRatio="none">
							<path d="M0 6h48M42 2.7 48 6l-6 3.3" vector-effect="non-scaling-stroke" />
						</svg>
						<svg class="pfg-arrow pfg-arrow--down" viewBox="0 0 12 48" preserveAspectRatio="none">
							<path d="M6 0v48M2.7 42 6 48l3.3-6" vector-effect="non-scaling-stroke" />
						</svg>
					</span>
				)}
			</li>
		))}
	</ol>
</div>
)}

<style>
	.process-flow-grid {
		/* ---- overridable tokens -------------------------------------------
		   Set any of these on the element, from a class, an id or a wrapper
		   rule, to retune the component without touching this file. */
		--pfg-card: 21rem; /* 336px */
		--pfg-gap: 3rem; /* 48px */
		--pfg-radius: 1.25rem; /* 20px */
		--pfg-pad: 1.75rem; /* 28px */

		--pfg-surface: #fff;
		--pfg-edge: rgb(15 23 42 / 0.08);
		--pfg-title: #0f172a;
		--pfg-body: #475569;
		--pfg-accent: #5865f2;
		/* The same accent held back, for an arrow out of a step that is not
		   done yet. One colour, two strengths — not a second hue. */
		--pfg-accent-quiet: rgb(88 101 242 / 0.18);
		--pfg-shadow:
			0 1px 2px rgb(15 23 42 / 0.06),
			0 12px 32px rgb(15 23 42 / 0.07);

		--pfg-lift: 4px;
		--pfg-lift-duration: 220ms;
		/* Below this the grid is one column and every arrow points down. */
		--pfg-stack-at: 46rem;

		box-sizing: border-box;
		container-type: inline-size;
		display: block;
		color: var(--pfg-title);
		font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
	}

	/* `:where` keeps specificity at zero, so a destination project's own rules
	   still win where it means them to. */
	.process-flow-grid :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.pfg-list {
		display: grid;
		grid-template-columns: repeat(2, minmax(0, var(--pfg-card)));
		justify-content: center;
		gap: var(--pfg-gap);
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.pfg-card {
		/* Set per card, which is what turns the second row around. */
		grid-column: var(--pfg-column);
		grid-row: var(--pfg-row);
		position: relative;
		display: flex;
		flex-direction: column;
		gap: 0.75rem;
		padding: var(--pfg-pad);
		background: var(--pfg-surface);
		border: 1px solid var(--pfg-edge);
		border-radius: var(--pfg-radius);
		box-shadow: var(--pfg-shadow);
		/*
		 * The individual property, not `transform`.
		 *
		 * A project that wants to add a transform of its own — a scale on
		 * focus, a skew in a parent — then does not have to reconstruct this
		 * one to avoid overwriting it.
		 */
		transition: translate var(--pfg-lift-duration) ease;
	}

	.pfg-card:hover {
		translate: 0 calc(var(--pfg-lift) * -1);
	}

	.pfg-title {
		margin: 0;
		font-size: 1.25rem;
		line-height: 1.3;
		font-weight: 400;
		letter-spacing: -0.01em;
		color: var(--pfg-title);
	}

	.pfg-body {
		margin: 0;
		font-size: 0.9375rem;
		line-height: 1.55;
		color: var(--pfg-body);
		text-wrap: pretty;
	}

	/* ---- the arrows ----------------------------------------------------- */

	/*
	 * Anchored to the card it leaves and sized to the gap, so a taller row
	 * moves the arrow with it. Nothing here depends on the card's height being
	 * what it was when the component was drawn.
	 */
	.pfg-link {
		position: absolute;
		color: var(--pfg-accent-quiet);
		pointer-events: none;
	}

	.pfg-card[data-pfg-live] .pfg-link {
		color: var(--pfg-accent);
	}

	.pfg-arrow {
		display: block;
		width: 100%;
		height: 100%;
		overflow: visible;
		fill: none;
		stroke: currentColor;
		stroke-width: 3;
		stroke-linecap: round;
		stroke-linejoin: round;
	}

	/* One shape per axis rather than one shape rotated: a rotation would have
	   to be undone and re-centred at the breakpoint, and this is two paths. */
	.pfg-arrow--down {
		display: none;
	}

	.pfg-card[data-pfg-direction="right"] .pfg-link,
	.pfg-card[data-pfg-direction="left"] .pfg-link {
		top: 50%;
		width: var(--pfg-gap);
		height: 0.75rem;
		translate: 0 -50%;
	}

	.pfg-card[data-pfg-direction="right"] .pfg-link {
		left: 100%;
	}

	.pfg-card[data-pfg-direction="left"] .pfg-link {
		right: 100%;
		scale: -1 1;
	}

	.pfg-card[data-pfg-direction="down"] .pfg-link {
		top: 100%;
		left: 50%;
		width: 0.75rem;
		height: var(--pfg-gap);
		translate: -50% 0;
	}

	.pfg-card[data-pfg-direction="down"] .pfg-arrow--across {
		display: none;
	}

	.pfg-card[data-pfg-direction="down"] .pfg-arrow--down {
		display: block;
	}

	/* ---- stacked --------------------------------------------------------
	   One column, so the back-and-forth is gone and every hand-off is the same
	   downward step. The cards keep their document order, which is why this is
	   only a matter of unsetting the column and re-pointing the arrows. */
	@container (max-width: 46rem) {
		.pfg-list {
			grid-template-columns: minmax(0, var(--pfg-card));
		}

		.pfg-card {
			grid-column: auto;
			grid-row: auto;
		}

		.pfg-card[data-pfg-direction]:not([data-pfg-direction="none"]) .pfg-link {
			top: 100%;
			right: auto;
			left: 50%;
			width: 0.75rem;
			height: var(--pfg-gap);
			translate: -50% 0;
			scale: 1 1;
		}

		.pfg-card .pfg-arrow--across {
			display: none;
		}

		.pfg-card .pfg-arrow--down {
			display: block;
		}
	}

	/* ---- reduced motion --------------------------------------------------
	   The lift is the only motion here, and it is the whole hover treatment —
	   so it is removed rather than shortened. A 0ms translate is a jump, and a
	   jump is more movement than none. */
	@media (prefers-reduced-motion: reduce) {
		.pfg-card {
			transition: none;
		}

		.pfg-card:hover {
			translate: none;
		}
	}
</style>

===== END src/components/ProcessFlowGrid.astro =====

===== FILE (required): src/components/TestimonialRoster.astro =====
---
/**
 * Testimonial roster
 *
 * A testimonial that comes with the person attached: a portrait card carrying
 * a headline statistic beside a quote card, and underneath them a roster of
 * everyone who has something to say. Pick a face, the pair changes together.
 *
 * Built as a tab set, because that is what it is — one panel, several named
 * choices, and the choice is the navigation. That buys arrow keys, Home and
 * End for free, and it is what the roster looks like it should do.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont.
 * Every colour and measurement is a `--tro-*` custom property on the root.
 * The portraits are the only files it reaches for, and they are props.
 */

export interface TestimonialRosterStat {
	/** The big number. Kept short — it is set at 42px. */
	value: string;
	/** The line under it. */
	label: string;
}

export interface TestimonialRosterEntry {
	/** Who said it. Also the roster label and the accessible name. */
	name: string;
	/** What they do. Shown beside the company under the quote. */
	role?: string;
	/** Where they do it. Shown in the roster and at the foot of the card. */
	company?: string;
	/** What they said. The quotation marks are drawn by the component. */
	quote: string;
	/**
	 * 0 to 5, and only ever the rating this person actually gave. Omit it and
	 * no stars are drawn: the component never supplies a rating on anyone's
	 * behalf. `0` also draws none rather than five empty ones — a rating
	 * nobody gave is not a rating of zero.
	 */
	rating?: number;
	/** Portrait for the big card. */
	image?: string;
	/**
	 * Description of the portrait. Leave it out and the portrait is marked
	 * decorative, which is right when the name is already beside it.
	 */
	imageAlt?: string;
	/** Roster thumbnail. Falls back to `image`. */
	avatar?: string;
	/** The figure over the portrait. Omit it and the portrait runs clean. */
	stat?: TestimonialRosterStat;
	/** The pill in the corner of the portrait. */
	badge?: string;
}

interface Props {
	/** Small line over the headline. `""` drops it. */
	eyebrow?: string;
	/** The section heading. Omit it (or `""`) and there is none. */
	headline?: string;
	/** Heading level for the headline, so it fits the page's outline. */
	headingLevel?: "h2" | "h3" | "h4";
	/**
	 * The people. Two or more makes a roster; one renders as a plain quote.
	 * None, and the component renders nothing at all: it never supplies a
	 * testimonial of its own.
	 */
	items?: TestimonialRosterEntry[];
	/** Which one is showing on arrival. Clamped into range. */
	initialIndex?: number;
	/** Accessible name for the roster itself. */
	rosterLabel?: string;
	/** The page colour behind the cards. */
	background?: string;
	/** Headline, quote and name. */
	ink?: string;
	/** Eyebrow, role, counter, company — everything secondary. */
	mutedInk?: string;
	/** The quote card's ground. */
	paper?: string;
	/** The stat card, the badge tint and the arrow hover. */
	accent?: string;
	/** Hairlines: the card's divider, the arrow borders, the roster rules. */
	rule?: string;
	/** Where the content stops widening. */
	maxWidth?: string;
	/** Added to the root. */
	class?: string;
	/** Root element id. Also seeds every generated id. */
	id?: string;
}

const {
	eyebrow = "In their own words",
	headline = "",
	headingLevel = "h2",
	items = [],
	initialIndex = 0,
	rosterLabel = "Choose a customer",
	background = "#ffffff",
	ink = "#20291f",
	mutedInk = "#6d7569",
	paper = "#f2f3ed",
	accent = "#d8edaa",
	rule = "#dde1d5",
	maxWidth = "1120px",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/*
 * The same shape every scripted component in this library uses. `id` wins when
 * the caller supplies one; otherwise a random suffix, because two instances on
 * one page must not emit the same ids and an index-derived id does exactly
 * that. This library has already shipped that bug once.
 */
const uid = id ?? `tro-${Math.random().toString(36).slice(2, 9)}`;

/* `uid` can arrive from the `id` prop, which is caller data. It goes into id
   attributes and into aria-controls, so anything that is not an identifier
   character comes out first. */
const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, "") || "tro";

const tabId = (i: number) => `${safeUid}-tab-${i}`;
const panelId = (i: number) => `${safeUid}-panel-${i}`;

/* A person with no name cannot be labelled, listed or announced. */
const people = (items ?? []).filter((p) => p?.name && p?.quote);
const count = people.length;

/* Clamped rather than trusted: an out-of-range index would select no tab at
   all and leave the roster with nothing pressed. */
const start = Number.isInteger(initialIndex)
	? Math.min(Math.max(initialIndex, 0), Math.max(count - 1, 0))
	: 0;

/* Only schemes that can actually resolve to an image. A roster tends to be
   filled from a content record rather than by hand. */
const SAFE_SRC = /^(?:https?:|data:image\/|[#/.])/i;
const safeSrc = (value?: string) =>
	value && SAFE_SRC.test(value.trim()) ? value.trim() : undefined;

const pad = (n: number) => String(n).padStart(2, "0");

/* No rating supplied means no stars — never a default five. */
const stars = (rating?: number) => {
	if (typeof rating !== "number" || !Number.isFinite(rating)) return 0;
	return Math.min(Math.max(Math.round(rating), 0), 5);
};

const style = [
	`--tro-bg:${background}`,
	`--tro-ink:${ink}`,
	`--tro-muted:${mutedInk}`,
	`--tro-paper:${paper}`,
	`--tro-accent:${accent}`,
	`--tro-rule:${rule}`,
	`--tro-max:${maxWidth}`,
].join(";");
---

{/* Nobody to quote, nothing to render — not even the header. */}
{count > 0 && (
<section
	class:list={["testimonial-roster", className]}
	id={id}
	style={style}
	data-tro-root
	data-tro-index={start}
	data-tro-count={count}
>
	<div class="tro-inner">
		{
			(eyebrow || headline) && (
				<header class="tro-head">
					{eyebrow && <p class="tro-eyebrow">{eyebrow}</p>}
					{headline && <Heading class="tro-headline">{headline}</Heading>}
				</header>
			)
		}

		{/*
		    Focusable, named and grouped for the no-script case, where this is a
		    scrolling strip and the only way to reach the other testimonials —
		    and a scroll container that cannot be focused cannot be scrolled
		    from the keyboard. The controller strips all three on boot, because
		    once it is a stack there is nothing to scroll and a focusable div
		    would just be a tab stop that does nothing.
		 */}
		<div
			class="tro-panels"
			tabindex="0"
			role="group"
			aria-label={rosterLabel}
		>
			{
				people.map((person, i) => {
					const img = safeSrc(person.image);
					const rated = stars(person.rating);
					return (
						<article
							class="tro-panel"
							id={panelId(i)}
							role="tabpanel"
							aria-labelledby={tabId(i)}
							data-tro-panel={i}
							hidden={undefined}
						>
							<div class="tro-portrait">
								{img ? (
									<img
										class="tro-photo"
										src={img}
										alt={person.imageAlt ?? ""}
										width="600"
										height="800"
										loading={i === start ? "eager" : "lazy"}
										decoding="async"
									/>
								) : (
									<div class="tro-photo tro-photo--empty" aria-hidden="true" />
								)}

								{person.badge && <p class="tro-badge">{person.badge}</p>}

								{person.stat && (
									<div class="tro-stat" data-tro-reveal>
										<span class="tro-stat-value">{person.stat.value}</span>
										<span class="tro-stat-label">{person.stat.label}</span>
									</div>
								)}
							</div>

							<div class="tro-card">
								<div class="tro-card-top">
									{rated > 0 ? (
										<span
											class="tro-stars"
											role="img"
											aria-label={`Rated ${rated} out of 5`}
										>
											{Array.from({ length: rated }).map(() => (
												<svg
													class="tro-star"
													viewBox="0 0 24 24"
													width="15"
													height="15"
													aria-hidden="true"
													focusable="false"
												>
													<path
														fill="currentColor"
														d="M12 2.6l2.9 6.05 6.6.92-4.8 4.62 1.18 6.56L12 17.66 6.12 20.75 7.3 14.19 2.5 9.57l6.6-.92z"
													/>
												</svg>
											))}
										</span>
									) : (
										<span />
									)}
									<span class="tro-counter">
										{pad(i + 1)} <span aria-hidden="true">/</span>{" "}
										{pad(count)}
									</span>
								</div>

								<div class="tro-body" data-tro-reveal>
									<blockquote class="tro-quote">{person.quote}</blockquote>
									<p class="tro-attrib">
										<span class="tro-name">{person.name}</span>
										{(person.role || person.company) && (
											<span class="tro-role">
												{[person.role, person.company]
													.filter(Boolean)
													.join(", ")}
											</span>
										)}
									</p>
								</div>

								<div class="tro-card-foot">
									<span class="tro-company">{person.company ?? ""}</span>
									{count > 1 && (
										<div class="tro-nav">
											<button
												type="button"
												class="tro-arrow"
												data-tro-step="-1"
												aria-label="Previous testimonial"
											>
												<svg
													viewBox="0 0 24 24"
													width="20"
													height="20"
													aria-hidden="true"
													focusable="false"
												>
													<path
														fill="none"
														stroke="currentColor"
														stroke-width="1.6"
														stroke-linecap="round"
														stroke-linejoin="round"
														d="M15 5l-7 7 7 7M8 12h11"
													/>
												</svg>
											</button>
											<button
												type="button"
												class="tro-arrow"
												data-tro-step="1"
												aria-label="Next testimonial"
											>
												<svg
													viewBox="0 0 24 24"
													width="20"
													height="20"
													aria-hidden="true"
													focusable="false"
												>
													<path
														fill="none"
														stroke="currentColor"
														stroke-width="1.6"
														stroke-linecap="round"
														stroke-linejoin="round"
														d="M9 5l7 7-7 7M5 12h11"
													/>
												</svg>
											</button>
										</div>
									)}
								</div>
							</div>
						</article>
					);
				})
			}
		</div>

		{
			count > 1 && (
				<div class="tro-roster" role="tablist" aria-label={rosterLabel}>
					{people.map((person, i) => {
						const face = safeSrc(person.avatar) ?? safeSrc(person.image);
						return (
							<button
								type="button"
								class="tro-tab"
								role="tab"
								id={tabId(i)}
								aria-controls={panelId(i)}
								aria-selected={i === start ? "true" : "false"}
								tabindex={i === start ? 0 : -1}
								data-tro-tab={i}
							>
								{face ? (
									<img
										class="tro-avatar"
										src={face}
										alt=""
										width="36"
										height="36"
										loading="lazy"
										decoding="async"
									/>
								) : (
									<span class="tro-avatar tro-avatar--empty" aria-hidden="true" />
								)}
								<span class="tro-tab-text">
									<span class="tro-tab-name">{person.name}</span>
									{person.company && (
										<span class="tro-tab-org">{person.company}</span>
									)}
								</span>
							</button>
						);
					})}
				</div>
			)
		}

		{/* Selecting a tab does not move focus, so nothing would read the new
		    quote. This does, briefly and politely. */}
		<p class="tro-live" aria-live="polite" data-tro-live />
	</div>
</section>
)}

<style>
	.testimonial-roster {
		--tro-radius: 24px;
		--tro-pad: 32px;
		--tro-gap: 18px;
		--tro-card-pad: 36px;
		--tro-inset: 20px;
		--tro-tab-min: 154px;
		--tro-tab-gap: 28px;

		--tro-eyebrow-size: 11px;
		--tro-headline-size: 38px;
		--tro-quote-size: 34px;
		--tro-stat-size: 42px;
		--tro-small: 12px;
		--tro-attrib-size: 14px;

		/* One token, so reduced motion has one place to reach. */
		--tro-duration: 450ms;
		--tro-ease: ease;

		/*
		 * The mono face carries the eyebrow, the counter, the attribution and
		 * the roster. It is a system stack: the component makes no network
		 * request, so there is no webfont to wait for and nothing to shift.
		 */
		--tro-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
			"Liberation Mono", monospace;

		display: block;
		box-sizing: border-box;
		/*
		 * The container is the ROOT, not .tro-inner, and that is a fix rather
		 * than a preference. An element is never matched by its own container
		 * query — a query container resolves against its nearest ANCESTOR
		 * container — so with the container on .tro-inner every token the
		 * breakpoints set on .tro-inner silently did nothing. Measured at
		 * 320px: the quote was still 34px and the headline still 38px.
		 *
		 * Moving it here also lines the breakpoint up with the reference's.
		 * .tro-inner is narrower than the root by the padding, so a 46rem query
		 * against it fired at a 768px window — where the reference is still two
		 * columns.
		 */
		container-type: inline-size;
		background: var(--tro-bg);
		color: var(--tro-ink);
		font-family: inherit;
	}

	.testimonial-roster *,
	.testimonial-roster *::before,
	.testimonial-roster *::after {
		box-sizing: border-box;
	}

	/*
	 * Every breakpoint below is a container query against the root, so the
	 * component lays out by the room it has rather than by the size of the
	 * window. Dropped into a narrow column it still does the right thing.
	 */
	/*
	 * Every string here arrives from a caller, and a testimonial is the kind of
	 * content that gets pasted in rather than written to fit. Measured without
	 * this: one unbreakable 56-character word in the quote pushed the document
	 * 360px past a 390px viewport. `anywhere` rather than `break-word` because
	 * only `anywhere` also shrinks the min-content size, which is what stops a
	 * long word forcing a grid or flex track open in the first place.
	 */
	.tro-headline,
	.tro-eyebrow,
	.tro-quote,
	.tro-name,
	.tro-role,
	.tro-company,
	.tro-stat-value,
	.tro-stat-label,
	.tro-badge,
	.tro-tab-name {
		overflow-wrap: anywhere;
	}

	.tro-inner {
		/*
		 * The padding lives here, not on the root, and that is load-bearing.
		 * `container-type: inline-size` queries the container's CONTENT box, so
		 * padding on the root would make every breakpoint fire 64px early — and
		 * because the padding itself changes at a breakpoint, the content box
		 * would jump outward as it narrowed and could flip the layout back and
		 * forth across a single pixel. Padding-free, the root's query width is
		 * simply the room the component has.
		 *
		 * The cap grows by the padding so `maxWidth` keeps meaning the width of
		 * the CONTENT, which is what it meant when the padding was outside it.
		 */
		max-inline-size: calc(var(--tro-max) + var(--tro-pad) * 2);
		margin-inline: auto;
		padding: var(--tro-pad);
	}

	/* ---- header ---------------------------------------------------------- */

	.tro-head {
		margin-block-end: 32px;
	}

	.tro-eyebrow {
		margin: 0 0 14px;
		color: var(--tro-muted);
		font-family: var(--tro-mono);
		font-size: var(--tro-eyebrow-size);
		font-weight: 600;
		/* 1.43px on an 11px face. */
		letter-spacing: 0.13em;
		line-height: 1.2;
		text-transform: uppercase;
	}

	.tro-headline {
		margin: 0;
		color: var(--tro-ink);
		font-size: var(--tro-headline-size);
		font-weight: 400;
		/* -1.52px on a 38px face. */
		letter-spacing: -0.04em;
		line-height: 1.1;
		/* Two lines that are close to equal, rather than one long and one short. */
		text-wrap: balance;
	}

	/* ---- the stack of panels --------------------------------------------- */

	/*
	 * Before the script runs, the panels are a horizontally scrollable strip:
	 * every testimonial is in the DOM, visible and reachable without a line of
	 * JavaScript. The controller turns the strip into a stack and shows one at
	 * a time. Nothing is hidden behind a script that has not run.
	 */
	.tro-panels {
		display: flex;
		gap: var(--tro-gap);
		overflow-x: auto;
		scroll-snap-type: x mandatory;
		scrollbar-width: thin;
	}

	.tro-panels > .tro-panel {
		flex: 0 0 100%;
		scroll-snap-align: start;
	}

	[data-tro-ready] .tro-panels {
		display: grid;
		gap: 0;
		overflow: visible;
		scroll-snap-type: none;
	}

	/* Stacked in one cell so the box never resizes as panels swap. */
	[data-tro-ready] .tro-panels > .tro-panel {
		grid-area: 1 / 1;
		flex: none;
	}

	[data-tro-ready] .tro-panel[hidden] {
		display: none;
	}

	.tro-panel {
		display: grid;
		/* 0.38 / 0.62 of the room left after the gap. */
		grid-template-columns: 0.38fr 0.62fr;
		gap: var(--tro-gap);
		margin: 0;
		min-inline-size: 0;
	}

	/* ---- portrait card --------------------------------------------------- */

	.tro-portrait {
		position: relative;
		overflow: hidden;
		border-radius: var(--tro-radius);
		background: var(--tro-accent);
		/*
		 * Side by side it has no height of its own — the quote card sets the
		 * row and this fills it. `min-block-size` is the floor for the case
		 * where a very short quote would otherwise squash it.
		 */
		min-block-size: 280px;
	}

	.tro-photo {
		/*
		 * Out of flow, and that is the point. Left in flow, an <img> with
		 * intrinsic dimensions sizes the card by its own aspect ratio — a
		 * 600x800 portrait in a 419px column made the row 557px tall and the
		 * quote card grew a third of its height in empty space to match.
		 * Absolute, the row is set by the quote card and the portrait fills
		 * whatever that turns out to be.
		 */
		position: absolute;
		inset: 0;
		display: block;
		inline-size: 100%;
		block-size: 100%;
		object-fit: cover;
		/* The head sits high in the artwork, so the crop is taken high. */
		object-position: 50% 40%;
	}

	.tro-photo--empty {
		background: var(--tro-accent);
	}

	.tro-badge {
		position: absolute;
		inset-block-start: var(--tro-inset);
		inset-inline-start: var(--tro-inset);
		/* Anchored on one side only, so it shrink-wraps its text and a long
		   label would run out past the card's far edge and be clipped. This
		   makes it wrap at the same inset instead. */
		max-inline-size: calc(100% - var(--tro-inset) * 2);
		margin: 0;
		padding: 8px 16px;
		border-radius: 99px;
		background: color-mix(in srgb, var(--tro-ink) 55%, transparent);
		color: #fff;
		font-family: var(--tro-mono);
		font-size: var(--tro-eyebrow-size);
		line-height: 1.4;
		/* Glassy over whatever the portrait happens to be behind it. */
		backdrop-filter: blur(6px);
	}

	.tro-stat {
		position: absolute;
		inset-block-end: var(--tro-inset);
		inset-inline: var(--tro-inset);
		padding: 18px 20px;
		border-radius: 14px;
		background: var(--tro-accent);
		color: var(--tro-ink);
	}

	.tro-stat-value {
		display: block;
		font-size: var(--tro-stat-size);
		font-weight: 400;
		letter-spacing: -0.03em;
		line-height: 1;
	}

	.tro-stat-label {
		display: block;
		margin-block-start: 8px;
		font-family: var(--tro-mono);
		font-size: var(--tro-small);
		line-height: 1.4;
	}

	/* ---- quote card ------------------------------------------------------ */

	.tro-card {
		display: flex;
		flex-direction: column;
		padding: var(--tro-card-pad);
		border-radius: var(--tro-radius);
		background: var(--tro-paper);
		min-inline-size: 0;
	}

	.tro-card-top {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 16px;
		/*
		 * Reserved rather than derived. With `rating: 0` there are no stars and
		 * the row would collapse to the counter's line box, moving the quote up
		 * on some cards and not others.
		 */
		min-block-size: 17px;
	}

	.tro-stars {
		display: inline-flex;
		gap: 4px;
		color: var(--tro-ink);
	}

	.tro-star {
		display: block;
	}

	.tro-counter {
		color: var(--tro-muted);
		font-family: var(--tro-mono);
		font-size: var(--tro-small);
		/* Fixed digits, so the row does not twitch as the number changes. */
		font-variant-numeric: tabular-nums;
		white-space: nowrap;
	}

	.tro-body {
		margin-block-start: 31px;
		/*
		 * A floor under the gap to the divider. The foot is pushed down by
		 * `margin-block-start: auto`, which distributes free space — and a card
		 * sized by its own content has none, so the divider ends up against the
		 * attribution. The reference leaves 23px here whatever the quote does.
		 */
		margin-block-end: 23px;
	}

	.tro-quote {
		margin: 0;
		color: var(--tro-ink);
		font-size: var(--tro-quote-size);
		font-weight: 400;
		/* -0.85px on a 34px face. */
		letter-spacing: -0.025em;
		line-height: 1.22;
		/* Curly quotes are drawn here rather than typed into the copy, so the
		   prop stays plain text and nothing double-quotes. */
		quotes: "\201C" "\201D";
	}

	.tro-quote::before {
		content: open-quote;
	}

	.tro-quote::after {
		content: close-quote;
	}

	.tro-attrib {
		display: flex;
		flex-direction: column;
		gap: 3px;
		margin: 24px 0 0;
		font-family: var(--tro-mono);
		font-size: var(--tro-attrib-size);
		line-height: 1.4;
	}

	.tro-name {
		color: var(--tro-ink);
		font-weight: 600;
	}

	.tro-role {
		color: var(--tro-muted);
	}

	.tro-card-foot {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 16px;
		/* Pinned to the bottom of the card however tall the quote is. */
		margin-block-start: auto;
		padding-block-start: 24px;
		border-block-start: 1px solid var(--tro-rule);
	}

	.tro-company {
		color: var(--tro-muted);
		font-family: var(--tro-mono);
		font-size: var(--tro-small);
	}

	/* Dead controls do not appear: the arrows are revealed by the controller. */
	.tro-nav {
		display: none;
		gap: 8px;
	}

	[data-tro-ready] .tro-nav {
		display: flex;
	}

	.tro-arrow {
		display: grid;
		place-items: center;
		inline-size: 44px;
		block-size: 44px;
		padding: 0;
		border: 1px solid var(--tro-rule);
		border-radius: 50%;
		background: transparent;
		color: var(--tro-ink);
		cursor: pointer;
		transition:
			background-color 160ms var(--tro-ease),
			transform 160ms var(--tro-ease);
	}

	.tro-arrow:hover {
		background-color: var(--tro-accent);
	}

	.tro-arrow:active {
		transform: scale(0.95);
	}

	/* ---- roster ---------------------------------------------------------- */

	/*
	 * Revealed by the controller, like the arrows. Without a script the roster
	 * would be four buttons that do nothing, announcing themselves as a tab set
	 * that cannot switch anything — worse than not being there, because the
	 * panels are already reachable as a strip. Absent, the no-script render is
	 * simply four testimonials you can scroll through.
	 */
	.tro-roster {
		display: none;
		gap: var(--tro-tab-gap);
		margin-block-start: 22px;
		overflow-x: auto;
		/* The controller scrolls the selected tab into view and reads this
		   to keep it off the edge when it gets there. */
		scroll-padding-inline: var(--tro-tab-gap);
		scrollbar-width: none;
	}

	[data-tro-ready] .tro-roster {
		display: flex;
	}

	.tro-roster::-webkit-scrollbar {
		display: none;
	}

	.tro-tab {
		display: flex;
		flex: 1 0 var(--tro-tab-min);
		align-items: center;
		gap: 10px;
		padding: 0 0 17px;
		border: 0;
		border-block-end: 2px solid var(--tro-rule);
		background: transparent;
		color: var(--tro-ink);
		cursor: pointer;
		opacity: 0.55;
		text-align: start;
		transition:
			opacity 180ms var(--tro-ease),
			border-color 180ms var(--tro-ease);
	}

	.tro-tab:hover {
		opacity: 0.8;
	}

	.tro-tab[aria-selected="true"] {
		border-block-end-color: var(--tro-ink);
		opacity: 1;
	}

	.tro-avatar {
		flex: none;
		inline-size: 36px;
		block-size: 36px;
		border-radius: 50%;
		background: var(--tro-accent);
		object-fit: cover;
		/* A tighter crop than the big card: this is 36px, so it is all head. */
		object-position: 50% 16%;
	}

	.tro-tab-text {
		display: flex;
		flex-direction: column;
		gap: 2px;
		min-inline-size: 0;
	}

	.tro-tab-name {
		color: var(--tro-ink);
		font-family: var(--tro-mono);
		font-size: var(--tro-small);
		line-height: 1.4;
	}

	.tro-tab-org {
		color: var(--tro-muted);
		font-family: var(--tro-mono);
		font-size: 11px;
		line-height: 1.4;
		overflow: hidden;
		text-overflow: ellipsis;
		white-space: nowrap;
	}

	/*
	 * Named on the classes rather than through a `:where(a, button)` group.
	 * Astro cannot put its scope attribute inside `:where()`, so it adds a
	 * second one beside it and the selector stops matching — this library has
	 * shipped a focus ring that computed as `medium none currentColor` for
	 * exactly that reason.
	 */
	.tro-tab:focus-visible,
	.tro-arrow:focus-visible {
		outline: 2px solid var(--tro-ink);
		outline-offset: 3px;
	}

	.tro-arrow:focus-visible {
		border-radius: 50%;
	}

	/* Announced, not shown. Not `display: none`, which is not announced. */
	.tro-live {
		position: absolute;
		inline-size: 1px;
		block-size: 1px;
		margin: -1px;
		padding: 0;
		border: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
	}

	/* ---- entrance -------------------------------------------------------- */

	/*
	 * The entrance runs because a panel goes from `display: none` to displayed,
	 * which restarts its animations from the beginning. No class has to be
	 * added and removed, and no reflow has to be forced to retrigger it.
	 */
	@keyframes tro-reveal {
		from {
			opacity: 0;
			transform: translateY(12px);
		}
		to {
			opacity: 1;
			transform: none;
		}
	}

	@keyframes tro-photo-in {
		from {
			opacity: 0.25;
			transform: scale(1.035);
		}
		to {
			opacity: 1;
			transform: none;
		}
	}

	@keyframes tro-fade {
		from {
			opacity: 0;
		}
		to {
			opacity: 1;
		}
	}

	[data-tro-ready] .tro-panel [data-tro-reveal] {
		animation: tro-reveal var(--tro-duration) var(--tro-ease) both;
	}

	[data-tro-ready] .tro-panel .tro-photo {
		animation: tro-photo-in var(--tro-duration) var(--tro-ease) both;
	}

	/* ---- preferences ----------------------------------------------------- */

	@media (prefers-reduced-motion: reduce) {
		/*
		 * The panel still changes, and it is still legible that it changed —
		 * what goes is the travel and the scale. A short cross-fade is left in
		 * place, because the objection is to motion rather than to a change in
		 * opacity, and a hard cut between two blocks of text is harder to
		 * follow, not easier.
		 */
		[data-tro-ready] .tro-panel [data-tro-reveal],
		[data-tro-ready] .tro-panel .tro-photo {
			animation-name: tro-fade;
			animation-duration: 120ms;
		}

		.tro-arrow,
		.tro-tab {
			transition-duration: 0.01ms;
		}

		.tro-arrow:active {
			transform: none;
		}

		.tro-roster {
			scroll-behavior: auto;
		}
	}

	/* ---- responsive ------------------------------------------------------ */

	/*
	 * Side by side, the portrait gets 0.38 of the row. Below about 46rem that
	 * leaves it under 250px against a quote set at 34px, and the quote card
	 * starts breaking words rather than lines. So the pair stacks, the portrait
	 * takes a fixed band, and the type steps down.
	 */
	@container (max-width: 47rem) {
		.tro-inner {
			--tro-pad: 20px;
			--tro-pad: 20px;
			--tro-card-pad: 24px;
			--tro-headline-size: 30.4px;
			--tro-quote-size: 27.2px;
			--tro-stat-size: 34px;
			--tro-tab-gap: 20px;
		}

		.tro-panel {
			grid-template-columns: minmax(0, 1fr);
		}

		.tro-portrait {
			block-size: 280px;
			min-block-size: 0;
		}

		.tro-card-foot {
			flex-wrap: wrap;
		}
	}

	@container (max-width: 27rem) {
		.tro-inner {
			--tro-headline-size: 26px;
			--tro-quote-size: 22px;
		}

		.tro-card-top {
			align-items: flex-start;
		}
	}

	/* ---- forced colours and print ---------------------------------------- */

	@media (forced-colors: active) {
		/*
		 * The tints are all colour on colour and none of them survive a forced
		 * palette, so the structure is redrawn in border terms, which do.
		 */
		.tro-portrait,
		.tro-card,
		.tro-stat {
			border: 1px solid CanvasText;
		}

		.tro-badge {
			border: 1px solid CanvasText;
			background: Canvas;
			color: CanvasText;
			backdrop-filter: none;
		}

		.tro-arrow {
			border-color: CanvasText;
			color: CanvasText;
		}

		.tro-arrow:hover {
			background-color: Highlight;
			color: HighlightText;
		}

		.tro-tab {
			/* Opacity is not a colour and a forced palette will not fix it, so
			   the inactive tabs are brought back to full and the selected one
			   is marked by its rule alone. */
			opacity: 1;
			border-block-end-color: CanvasText;
		}

		.tro-tab[aria-selected="true"] {
			border-block-end-color: Highlight;
		}

		.tro-tab:focus-visible,
		.tro-arrow:focus-visible {
			outline-color: Highlight;
		}
	}

	@media print {
		.testimonial-roster {
			background: #fff;
			color: #000;
		}

		.tro-nav,
		.tro-roster {
			display: none;
		}

		/* On paper there is no "current" panel, so they all print. */
		[data-tro-ready] .tro-panel[hidden] {
			display: grid;
		}

		[data-tro-ready] .tro-panel [data-tro-reveal],
		[data-tro-ready] .tro-panel .tro-photo {
			animation: none;
			opacity: 1;
			transform: none;
		}
	}
</style>

<script>
	/*
	 * Roster controller.
	 *
	 * One controller per root, booted idempotently so it is safe to run again
	 * after a client-side navigation, and torn down on `astro:before-swap` so
	 * nothing survives into the next page.
	 *
	 * Every listener goes on one AbortController per instance. Teardown is
	 * then a single `abort()` rather than a list of removeEventListener calls
	 * that has to be kept in step with the list above it.
	 */
	type Instance = { destroy: () => void };

	const BOOTED = new WeakMap<HTMLElement, Instance>();

	function boot(root: HTMLElement) {
		if (BOOTED.has(root)) return;

		const panels = Array.from(
			root.querySelectorAll<HTMLElement>("[data-tro-panel]"),
		);
		const tabs = Array.from(root.querySelectorAll<HTMLElement>("[data-tro-tab]"));
		const live = root.querySelector<HTMLElement>("[data-tro-live]");
		if (panels.length === 0) return;

		const controller = new AbortController();
		const { signal } = controller;

		const count = panels.length;
		const parsed = Number(root.dataset.troIndex);
		let index = Number.isInteger(parsed) ? Math.min(Math.max(parsed, 0), count - 1) : 0;

		/* Wrapping is a modulo rather than a clamp, so the arrows loop in both
		   directions and neither one is ever a dead control. */
		const wrap = (i: number) => ((i % count) + count) % count;

		const roster = root.querySelector<HTMLElement>(".tro-roster");

		/*
		 * Keeps the selected face on screen when the roster is a scroller,
		 * which it is on a phone. Scrolls the roster and nothing else: this
		 * used to be scrollIntoView, which also scrolls every ancestor that
		 * can scroll - the window included - so a roster below the fold
		 * dragged the page down to itself on load with no input at all.
		 * Measured from rects rather than offsetLeft, which is relative to
		 * whatever the offsetParent happens to be in the host page.
		 */
		function revealTab(tab: HTMLElement) {
			if (!roster || roster.scrollWidth <= roster.clientWidth) return;
			const pad = parseFloat(getComputedStyle(roster).scrollPaddingLeft) || 0;
			const box = roster.getBoundingClientRect();
			const rect = tab.getBoundingClientRect();
			const start = rect.left - box.left + roster.scrollLeft;
			const end = start + rect.width;
			if (start - pad < roster.scrollLeft) {
				roster.scrollLeft = start - pad;
			} else if (end + pad > roster.scrollLeft + roster.clientWidth) {
				roster.scrollLeft = end + pad - roster.clientWidth;
			}
		}

		function render(next: number, moveFocus: boolean) {
			index = wrap(next);
			root.dataset.troIndex = String(index);

			panels.forEach((panel, i) => {
				if (i === index) panel.removeAttribute("hidden");
				else panel.setAttribute("hidden", "");
			});

			tabs.forEach((tab, i) => {
				const on = i === index;
				tab.setAttribute("aria-selected", on ? "true" : "false");
				/* Roving tabindex: one stop for the whole roster, not one per
				   person, which is what the tab pattern asks for. */
				tab.tabIndex = on ? 0 : -1;
			});

			const current = tabs[index];
			if (current) {
				if (moveFocus) current.focus();
				revealTab(current);
			}

			if (live) {
				const panel = panels[index];
				const name = panel?.querySelector(".tro-name")?.textContent?.trim() ?? "";
				const org = panel?.querySelector(".tro-company")?.textContent?.trim() ?? "";
				/* Deliberately short: the panel itself is what a reader moves to
				   next, and repeating the whole quote here would say it twice. */
				live.textContent = `Testimonial ${index + 1} of ${count}${
					name ? `, ${name}` : ""
				}${org ? `, ${org}` : ""}`;
			}
		}

		root.addEventListener(
			"click",
			(event) => {
				const target = event.target as Element | null;

				const tab = target?.closest<HTMLElement>("[data-tro-tab]");
				if (tab && root.contains(tab)) {
					render(Number(tab.dataset.troTab), false);
					return;
				}

				const arrow = target?.closest<HTMLElement>("[data-tro-step]");
				if (arrow && root.contains(arrow)) {
					render(index + Number(arrow.dataset.troStep), false);
					/* The arrow that was clicked belongs to the panel that is
					   about to be hidden, so focus would land on <body>. Put it
					   on the equivalent arrow in the panel that replaced it. */
					const step = arrow.dataset.troStep;
					const replacement = panels[index]?.querySelector<HTMLElement>(
						`[data-tro-step="${step}"]`,
					);
					replacement?.focus();
				}
			},
			{ signal },
		);

		root.addEventListener(
			"keydown",
			(event) => {
				const target = event.target as Element | null;
				const tab = target?.closest<HTMLElement>("[data-tro-tab]");
				if (!tab || !root.contains(tab)) return;

				/*
				 * Measured from the tab that has FOCUS, not from the selected
				 * index. Under the tab pattern's automatic activation the two
				 * are the same, and they stay the same as long as nothing else
				 * moves focus — which is exactly the assumption worth not
				 * making. Reading it off the event's own target cannot drift.
				 */
				const from = Number(tab.dataset.troTab);
				const keys: Record<string, number> = {
					ArrowLeft: from - 1,
					ArrowRight: from + 1,
					ArrowUp: from - 1,
					ArrowDown: from + 1,
					Home: 0,
					End: count - 1,
				};
				const next = keys[event.key];
				if (next === undefined) return;

				event.preventDefault();
				render(next, true);
			},
			{ signal },
		);

		/* The strip the no-script markup renders may have been scrolled before
		   the controller took over; the stack has no scroll, so reset it, and
		   give up the tab stop it only needed while it was a scroller. */
		const strip = root.querySelector<HTMLElement>(".tro-panels");
		const stripLabel = strip ? strip.getAttribute("aria-label") : null;
		if (strip) {
			strip.scrollLeft = 0;
			strip.removeAttribute("tabindex");
			strip.removeAttribute("role");
			strip.removeAttribute("aria-label");
		}

		root.dataset.troReady = "";
		render(index, false);

		BOOTED.set(root, {
			destroy() {
				controller.abort();
				delete root.dataset.troReady;
				/* Leave the DOM the way the server sent it, so a re-boot starts
				   from the same place a fresh render would — including the
				   attributes boot took off the strip. */
				panels.forEach((panel) => panel.removeAttribute("hidden"));
				if (strip) {
					strip.tabIndex = 0;
					strip.setAttribute("role", "group");
					if (stripLabel !== null) strip.setAttribute("aria-label", stripLabel);
				}
				BOOTED.delete(root);
			},
		});
	}

	function bootAll() {
		document
			.querySelectorAll<HTMLElement>("[data-tro-root]")
			.forEach((root) => boot(root));
	}

	function teardownAll() {
		document.querySelectorAll<HTMLElement>("[data-tro-root]").forEach((root) => {
			BOOTED.get(root)?.destroy();
		});
	}

	/* Astro's <script> is a deferred module, so the document is already parsed
	   by the time this runs and there is no DOMContentLoaded to wait for. */
	bootAll();

	document.addEventListener("astro:page-load", bootAll);
	document.addEventListener("astro:before-swap", teardownAll);
</script>

===== END src/components/TestimonialRoster.astro =====

===== FILE (required): public/testimonial-roster/portrait-1.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" width="600" height="800" role="img">
  <defs><linearGradient id="p1" x1="0" y1="0" x2="0.4" y2="1">
    <stop offset="0" stop-color="#5c7248"/><stop offset="1" stop-color="#2f3d25"/></linearGradient></defs>
  <rect width="600" height="800" fill="url(#p1)"/>
  <circle cx="470" cy="146" r="38" fill="#d8edaa" opacity="0.85"/>
  <path d="M300 188c-84 0-126 54-126 132 0 46 10 86 24 118 8-52 4-96 18-132 26 26 62 40 102 40s72-14 96-38c12 36 10 80 18 130 14-32 22-72 22-118 0-78-40-132-124-132z" fill="#2b2119"/>
  <circle cx="300" cy="322" r="112" fill="#efe4d2"/>
  <path d="M132 800c0-104 74-176 168-176s168 72 168 176z" fill="#efe4d2" opacity="0.96"/>
</svg>

===== END public/testimonial-roster/portrait-1.svg =====

===== FILE (required): public/testimonial-roster/portrait-2.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" width="600" height="800" role="img">
  <defs><linearGradient id="p2" x1="0" y1="0" x2="0.4" y2="1">
    <stop offset="0" stop-color="#b4704a"/><stop offset="1" stop-color="#5e3323"/></linearGradient></defs>
  <rect width="600" height="800" fill="url(#p2)"/>
  <circle cx="470" cy="146" r="38" fill="#ffd9a8" opacity="0.85"/>
  <path d="M300 214c-64 0-104 36-108 88 0 12 2 24 6 34 10-40 44-62 102-62s92 22 102 62c4-10 6-22 6-34-4-52-44-88-108-88z" fill="#241a14"/>
  <circle cx="300" cy="330" r="106" fill="#f3ddc6"/>
  <path d="M120 800c0-110 80-186 180-186s180 76 180 186z" fill="#f3ddc6" opacity="0.96"/>
</svg>

===== END public/testimonial-roster/portrait-2.svg =====

===== FILE (required): public/testimonial-roster/portrait-3.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" width="600" height="800" role="img">
  <defs><linearGradient id="p3" x1="0" y1="0" x2="0.4" y2="1">
    <stop offset="0" stop-color="#4a5f8c"/><stop offset="1" stop-color="#232c45"/></linearGradient></defs>
  <rect width="600" height="800" fill="url(#p3)"/>
  <circle cx="470" cy="146" r="38" fill="#c8d6ff" opacity="0.85"/>
  <path d="M302 186c-88 0-130 58-130 140 0 118-14 196-30 264h74c-14-70-8-160 0-214 24 34 56 52 86 52s62-18 86-52c8 54 14 144 0 214h74c-16-68-30-146-30-264 0-82-42-140-130-140z" fill="#1b1620"/>
  <circle cx="302" cy="318" r="110" fill="#e9d8c6"/>
  <path d="M136 800c0-102 74-172 166-172s166 70 166 172z" fill="#e9d8c6" opacity="0.96"/>
</svg>

===== END public/testimonial-roster/portrait-3.svg =====

===== FILE (required): public/testimonial-roster/portrait-4.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" width="600" height="800" role="img">
  <defs><linearGradient id="p4" x1="0" y1="0" x2="0.4" y2="1">
    <stop offset="0" stop-color="#8a7f63"/><stop offset="1" stop-color="#3d3628"/></linearGradient></defs>
  <rect width="600" height="800" fill="url(#p4)"/>
  <circle cx="470" cy="146" r="38" fill="#e8dcb8" opacity="0.85"/>
  <g fill="#332620"><circle cx="240" cy="224" r="54"/><circle cx="300" cy="200" r="60"/><circle cx="360" cy="226" r="54"/><circle cx="206" cy="274" r="46"/><circle cx="394" cy="276" r="46"/></g>
  <path d="M298 214c-70 0-112 42-112 104 0 18 4 34 10 48 8-46 46-72 102-72s94 26 102 72c6-14 10-30 10-48 0-62-42-104-112-104z" fill="#332620"/>
  <circle cx="298" cy="334" r="104" fill="#f0e2cd"/>
  <path d="M126 800c0-108 78-182 174-182s174 74 174 182z" fill="#f0e2cd" opacity="0.96"/>
</svg>

===== END public/testimonial-roster/portrait-4.svg =====

===== FILE (required): src/components/FaqAccordion.astro =====
---
/**
 * FAQ accordion
 *
 * Numbered questions stacked as one card. Closed rows sit flush against each
 * other behind hairlines; the open one lifts out of the stack as a card of its
 * own, and the rows either side of the gap round off to meet it.
 *
 * Built on `<details>` and `<summary>`, so the disclosure works before any
 * script runs. The controller's job is the animation and the one-at-a-time
 * rule, not the opening itself.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont.
 * Every colour, size and duration is a `--faq-*` custom property on the root.
 */

/** A run of answer text, or a link inside it. */
export type FaqAnswerPart = string | { text: string; href: string; external?: boolean };

export interface FaqAccordionItem {
	/** The question. Shown as written. */
	question: string;
	/**
	 * The answer. A plain string, or a list of runs so a link can sit inside
	 * the sentence rather than after it.
	 */
	answer: string | FaqAnswerPart[];
	/** Overrides the generated index. Omit and it is the position, zero-padded. */
	number?: string;
}

interface Props {
	/** Section heading. `""` hides it. */
	heading?: string;
	/** For pages where `h2` is the wrong level. */
	headingLevel?: "h1" | "h2" | "h3";
	/** Line under the heading. `""` hides it. */
	lead?: string;
	/** The questions, in order. */
	items?: FaqAccordionItem[];
	/**
	 * Which row starts open, counting from 1. `1` by default, because a stack
	 * of shut rows does not show what the component does. `0` opens none.
	 */
	initialOpen?: number;
	/**
	 * Let more than one row stand open. Off by default: the rule is what makes
	 * this an accordion rather than a stack of disclosures.
	 */
	allowMultiple?: boolean;
	/** Show the `01`, `02` badge before each question. */
	numbered?: boolean;
	/** Link under the list, e.g. to a full FAQ page. Needs both halves. */
	moreHref?: string;
	moreLabel?: string;
	/** The ground the section paints. */
	background?: string;
	/** The card fill. */
	surface?: string;
	/** Badge text, chevron, links and focus ring. */
	accent?: string;
	/** Badge fill. A pale wash of the accent. */
	accentSoft?: string;
	/** Heading and question colour. */
	ink?: string;
	/** Lead and answer colour. */
	muted?: string;
	/** Hairline between closed rows. */
	ruleColor?: string;
	/** Card corner radius. */
	radius?: string;
	/** Added to the root `<section>`. */
	class?: string;
	/** Root element id. Also seeds the exclusivity group name. */
	id?: string;
}

const {
	heading = "",
	headingLevel = "h2",
	lead = "",
	items = [],
	initialOpen = 1,
	allowMultiple = false,
	numbered = true,
	moreHref,
	moreLabel = "Read every question",
	background = "#eee6e0",
	surface = "#ffffff",
	accent = "#a31b48",
	accentSoft = "#fbf1f4",
	ink = "#1f1a1c",
	muted = "#666666",
	ruleColor = "#e4ddd8",
	radius = "14px",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/* A row needs both halves: a question with no answer, or the reverse, is
   dropped rather than drawn half-empty. No rows, no section. */
const hasAnswer = (answer: FaqAccordionItem["answer"] | undefined) =>
	typeof answer === "string"
		? answer.trim() !== ""
		: Array.isArray(answer) && answer.some((part) => (typeof part === "string" ? part.trim() : part?.text?.trim()));
const list = items.filter(
	(item) => typeof item?.question === "string" && item.question.trim() !== "" && hasAnswer(item.answer),
);

/*
 * One group name per instance. Two of these on a page must not close each
 * other's rows, and `name` is a document-wide identifier.
 */
const uid = id ?? `faq-${Math.random().toString(36).slice(2, 9)}`;

/* 1-based in, 0-based out. Anything outside the list opens nothing, which is
   the same as asking for none rather than an error. */
const openIndex =
	Number.isFinite(Number(initialOpen)) &&
	Math.trunc(Number(initialOpen)) >= 1 &&
	Math.trunc(Number(initialOpen)) <= list.length
		? Math.trunc(Number(initialOpen)) - 1
		: -1;

/*
 * Only schemes that navigate. A link in an answer is author-supplied, but this
 * component is copied into other people's projects where the answers may not
 * be — so `javascript:` never reaches an href.
 */
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE_SCHEME.test(href.trim()) ? href.trim() : undefined);

/** One answer, normalised to a list of runs so the template has one shape. */
const parts = (answer: FaqAccordionItem["answer"]): FaqAnswerPart[] =>
	typeof answer === "string" ? [answer] : answer;

const showMore = Boolean(moreHref && moreLabel && safeHref(moreHref));

const style = [
	`--faq-ground:${background}`,
	`--faq-surface:${surface}`,
	`--faq-accent:${accent}`,
	`--faq-accent-soft:${accentSoft}`,
	`--faq-ink:${ink}`,
	`--faq-muted:${muted}`,
	`--faq-rule:${ruleColor}`,
	`--faq-radius:${radius}`,
].join(";");
---

{list.length > 0 && (
<section
	class:list={["faq-accordion", className]}
	id={id}
	style={style}
	data-faq-accordion
	data-faq-multiple={allowMultiple ? "1" : "0"}
	aria-labelledby={heading ? `${uid}-heading` : undefined}
	aria-label={heading ? undefined : "Frequently asked questions"}
>
	<div class="faq-inner">
		{(heading || lead) && (
			<div class="faq-head">
				{heading && <Heading class="faq-heading" id={`${uid}-heading`}>{heading}</Heading>}
				{lead && <p class="faq-lead">{lead}</p>}
			</div>
		)}

		{/* Ordered, because the design numbers the rows. The visible badges are
		    aria-hidden so the position is announced once, by the list. */}
		<ol class="faq-list">
			{list.map((item, index) => (
				<li class="faq-item">
					<details
						class="faq-row"
						data-faq-row
						name={allowMultiple ? undefined : uid}
						open={index === openIndex}
					>
						<summary class="faq-summary" data-faq-summary>
							{numbered && (
								<span class="faq-num" aria-hidden="true">
									{item.number ?? String(index + 1).padStart(2, "0")}
								</span>
							)}
							<span class="faq-question">{item.question}</span>
							{/* Drawn from two borders on a pseudo-element rather than
							    shipped as an icon file, so it inherits the accent and
							    costs no request. */}
							<span class="faq-chevron" aria-hidden="true"></span>
						</summary>
						<div class="faq-panel">
							<div class="faq-panel-inner">
								<p class="faq-answer">
									{parts(item.answer).map((part) => {
										if (typeof part === "string") return part;
										const href = safeHref(part.href);
										if (!href) return part.text;
										return (
											<a
												class="faq-a"
												href={href}
												target={part.external ? "_blank" : undefined}
												rel={part.external ? "noopener noreferrer" : undefined}
											>
												{part.text}
											</a>
										);
									})}
								</p>
							</div>
						</div>
					</details>
				</li>
			))}
		</ol>

		{showMore && (
			<p class="faq-more">
				<a class="faq-link" href={safeHref(moreHref!)}>
					{moreLabel}
					<span class="faq-arrow" aria-hidden="true">→</span>
				</a>
			</p>
		)}
	</div>
</section>
)}

<script>
	/*
	 * What this controller is and is not for.
	 *
	 * The disclosure already works: it is <details> and <summary>, and before
	 * a line of this runs a row opens, closes, takes focus and announces its
	 * state, with `name` giving the one-at-a-time rule natively.
	 *
	 * What the browser cannot do on its own is animate the close — `open` goes
	 * false and the content is gone in the same frame — or hold one row open
	 * while closing another with both animations visible.
	 *
	 * So the controller suppresses the native toggle and adds ONE attribute:
	 * `data-faq-shut`, meaning "render this as shut whatever `open` says".
	 * Every open style in the stylesheet is written against
	 * `[open]:not([data-faq-shut])`, so the same dozen rules serve both worlds.
	 * With no script the guard never appears and `[open]` is simply the open
	 * state.
	 *
	 * With script it earns its keep on both edges:
	 *
	 * - closing — it goes on first, so the card drops back into the stack
	 *   while `open` stays long enough for the animation to run out;
	 * - opening — it goes on for exactly one frame, so the panel is rendered
	 *   shut before it is asked to grow. Without that frame a row that has
	 *   never been opened has nothing to animate from and jumps.
	 */
	interface Instance {
		destroy(): void;
	}

	function initFaqAccordion(root: HTMLElement): Instance | undefined {
		/* Idempotent: booting twice is a no-op, not a second set of listeners. */
		if (root.dataset.faqReady === "") return undefined;

		const rows = Array.from(root.querySelectorAll<HTMLDetailsElement>("[data-faq-row]"));
		if (rows.length === 0) return undefined;

		root.dataset.faqReady = "";
		const controller = new AbortController();
		const { signal } = controller;

		const single = root.dataset.faqMultiple !== "1";
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");

		/* The served state, kept so teardown can put it back rather than
		   leaving whatever the reader happened to leave open. */
		const servedName = new Map<HTMLDetailsElement, string>();
		const servedOpen = new Map<HTMLDetailsElement, boolean>();
		const timers = new Map<HTMLDetailsElement, number>();

		for (const row of rows) {
			servedName.set(row, row.name);
			servedOpen.set(row, row.open);
			/*
			 * Exclusivity moves from the browser to here. Left in place, the UA
			 * would close the sibling the instant this one opened, and the
			 * closing animation would never have a frame to run in.
			 */
			row.name = "";
		}

		/**
		 * How long a row takes to close, read from the stylesheet.
		 *
		 * `open` has to be cleared when the closing animation ends, and the
		 * length lives in a custom property so a project can retune it. Reading
		 * it back is what stops the two drifting apart the first time somebody
		 * changes one of them.
		 */
		function closeMs(): number {
			const raw = getComputedStyle(root).getPropertyValue("--faq-close-duration").trim();
			const value = parseFloat(raw);
			if (!Number.isFinite(value)) return 280;
			return raw.endsWith("ms") ? value : value * 1000;
		}

		function clearTimer(row: HTMLDetailsElement) {
			const timer = timers.get(row);
			if (timer !== undefined) window.clearTimeout(timer);
			timers.delete(row);
		}

		function openRow(row: HTMLDetailsElement) {
			/* A close may be in flight on this very row — catch it before it
			   lands and takes the row shut underneath the new open. */
			clearTimer(row);
			if (reduced.matches) {
				delete row.dataset.faqShut;
				row.open = true;
				return;
			}
			/*
			 * The panel of a row that has never been opened has never been
			 * rendered, so its `0fr` start frame does not exist and the
			 * transition has nothing to run from — the row would jump to full
			 * height. Holding the guard on while `open` goes true renders the
			 * panel shut; reading a layout property commits that frame; then
			 * releasing the guard is a change the browser can animate.
			 *
			 * Rows opened once already have the frame cached, which is exactly
			 * why this defect hides: the first row tested animates, every row
			 * after it snaps.
			 */
			row.dataset.faqShut = "";
			row.open = true;
			void row.offsetHeight;
			delete row.dataset.faqShut;
		}

		function closeRow(row: HTMLDetailsElement) {
			clearTimer(row);
			if (reduced.matches) {
				row.open = false;
				return;
			}
			/* The guard drops the open styling now; `open` follows when the
			   animation has finished with it. */
			row.dataset.faqShut = "";
			timers.set(
				row,
				window.setTimeout(() => {
					timers.delete(row);
					delete row.dataset.faqShut;
					row.open = false;
				}, closeMs()),
			);
		}

		for (const row of rows) {
			const summary = row.querySelector<HTMLElement>("[data-faq-summary]");
			if (!summary) continue;

			/* Enter and Space on a <summary> arrive here too, so this one
			   listener covers pointer and keyboard alike. */
			summary.addEventListener(
				"click",
				(event) => {
					event.preventDefault();
					/* Mid-close counts as closed: the row is on its way out, and a
					   click on it should bring it back rather than shut it again. */
					const showing = row.open && row.dataset.faqShut === undefined;
					if (showing) {
						closeRow(row);
						return;
					}
					if (single) {
						for (const other of rows) {
							if (other !== row && other.open && other.dataset.faqShut === undefined) {
								closeRow(other);
							}
						}
					}
					openRow(row);
				},
				{ signal },
			);
		}

		function destroy() {
			for (const row of rows) {
				clearTimer(row);
				delete row.dataset.faqShut;
				const name = servedName.get(row);
				if (name) row.name = name;
				row.open = servedOpen.get(row) ?? false;
			}
			timers.clear();
			delete root.dataset.faqReady;
			controller.abort();
		}

		/*
		 * Astro replaces the document on a client-side navigation. Detach every
		 * listener, return the section to its served state, and clear the ready
		 * flag so a re-used element can boot again.
		 */
		document.addEventListener("astro:before-swap", destroy, { once: true, signal });

		return { destroy };
	}

	const boot = () =>
		document
			.querySelectorAll<HTMLElement>("[data-faq-accordion]")
			.forEach(initFaqAccordion);

	/* Astro's own <script> is a deferred module, so the DOM is already parsed
	   by the time this runs. The readyState check is for the case where this
	   file is copied into a project that inlines it some other way. */
	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	/* Fires on every view transition in a project using <ClientRouter />, and
	   never at all in one that does not. Registered once per module, so it
	   cannot accumulate. */
	document.addEventListener("astro:page-load", boot);
</script>

<style>
	.faq-accordion {
		/*
		 * The three ramps below are container queries against this element
		 * rather than the viewport, so the section reads the same dropped into
		 * a narrow column as it does full-bleed. Each coefficient is the
		 * measured desktop value divided by 1440.
		 *
		 * The padding is deliberately not on this element: `cqi` resolves
		 * against the query container's CONTENT box, so padding here would
		 * subtract itself from every ramp underneath it.
		 */
		container-type: inline-size;
		box-sizing: border-box;

		--faq-pad-block: clamp(1.5rem, 3.33cqi, 3rem);
		--faq-pad-inline: clamp(1.25rem, 3.33cqi, 3rem);
		--faq-head-gap: clamp(1.75rem, 2.76cqi, 2.485rem);
		--faq-heading-size: clamp(2rem, 2.7cqi, 2.43rem);

		/* Fixed, because the reference holds them fixed: a question set at
		   18px is already at the size it should be read at on a phone. */
		--faq-lead-size: 1.0625rem;
		--faq-question-size: 1.125rem;
		--faq-answer-size: 1rem;
		/* Derived rather than written twice, so the badge and the indent that
		   has to clear it can never disagree. */
		--faq-badge-size: 0.9375rem;
		--faq-badge: calc(var(--faq-badge-size) * 2.2);
		--faq-row-pad-block: 1rem;
		--faq-row-pad-inline: 1rem;
		--faq-row-gap: 1rem;
		/* Level with the row's own padding until there is room to clear the
		   badge; the container query below takes over from there. */
		--faq-answer-indent: var(--faq-row-pad-inline);
		--faq-touch: 52px;
		--faq-open-gap: 12px;

		--faq-shadow: 0 1px 2px rgb(63 42 50 / 0.06);
		--faq-shadow-open:
			0 10px 28px rgb(66 42 48 / 0.07),
			0 2px 6px rgb(66 42 48 / 0.04);

		/*
		 * Two curves, deliberately. Opening is a 420ms ease-in-out, which lets
		 * the card settle into the gap it just made; closing is a shorter
		 * 280ms ease-out, because a row folding away should not ask to be
		 * watched. Taken from the reference, which does the same.
		 */
		--faq-open-duration: 420ms;
		--faq-open-ease: cubic-bezier(0.4, 0, 0.2, 1);
		--faq-close-duration: 280ms;
		--faq-close-ease: cubic-bezier(0.33, 1, 0.68, 1);

		background: var(--faq-ground);
		color: var(--faq-ink);
		padding-block: var(--faq-pad-block);
		/* A system stack on purpose: a component in this library fetches
		   nothing, so it can never be waiting on a webfont. */
		font-family:
			system-ui,
			-apple-system,
			"Segoe UI",
			Roboto,
			Helvetica,
			Arial,
			sans-serif;
	}

	.faq-inner {
		box-sizing: border-box;
		max-width: 80rem;
		margin-inline: auto;
		padding-inline: var(--faq-pad-inline);
	}

	/* ---- heading block ------------------------------------------------- */

	.faq-head {
		text-align: center;
		margin-block-end: var(--faq-head-gap);
	}

	.faq-heading {
		margin: 0;
		font-family: Georgia, "Times New Roman", Times, serif;
		font-size: var(--faq-heading-size);
		font-weight: 500;
		line-height: 1.1;
		letter-spacing: -0.02em;
		color: var(--faq-ink);
	}

	.faq-lead {
		max-width: 44rem;
		margin: 1rem auto 0;
		font-size: var(--faq-lead-size);
		line-height: 1.4;
		color: var(--faq-muted);
	}

	/* ---- the stack ----------------------------------------------------- */

	.faq-list {
		/* 832px: the width the rows were measured at, and about as wide as a
		   one-line question wants to be before it stops scanning. */
		max-width: 52rem;
		margin: 0 auto;
		padding: 0;
		list-style: none;
	}

	.faq-row {
		background: var(--faq-surface);
		border-radius: 0;
		box-shadow: var(--faq-shadow);
		/*
		 * Everything that moves when a row opens is listed here, on the close
		 * curve. The open state overrides the duration and the curve rather
		 * than the property list, so the two directions cannot drift apart.
		 */
		transition:
			margin var(--faq-close-duration) var(--faq-close-ease),
			border-radius var(--faq-close-duration) var(--faq-close-ease),
			box-shadow var(--faq-close-duration) var(--faq-close-ease),
			border-color var(--faq-close-duration) var(--faq-close-ease);
	}

	/* A hairline between neighbours, never above the first row. */
	.faq-item + .faq-item .faq-row {
		border-block-start: 1px solid var(--faq-rule);
	}

	.faq-item:first-child .faq-row {
		border-start-start-radius: var(--faq-radius);
		border-start-end-radius: var(--faq-radius);
	}

	.faq-item:last-child .faq-row {
		border-end-start-radius: var(--faq-radius);
		border-end-end-radius: var(--faq-radius);
	}

	/*
	 * The open row leaves the stack.
	 *
	 * `[open]:not([data-faq-shut])` is the one condition every open style
	 * is written against. With no script the guard never appears and this is
	 * just `[open]`; with script it goes on at the START of a close, so the
	 * card drops back into the stack on the close curve while its content is
	 * still there to animate.
	 */
	.faq-item:has(> .faq-row[open]:not([data-faq-shut])) .faq-row {
		margin-block: var(--faq-open-gap);
		border-radius: var(--faq-radius);
		box-shadow: var(--faq-shadow-open);
		transition-duration: var(--faq-open-duration);
		transition-timing-function: var(--faq-open-ease);
	}

	/* No outer gap at the ends of the list — there is nothing to part from. */
	.faq-item:first-child:has(> .faq-row[open]:not([data-faq-shut])) .faq-row {
		margin-block-start: 0;
	}

	.faq-item:last-child:has(> .faq-row[open]:not([data-faq-shut])) .faq-row {
		margin-block-end: 0;
	}

	/* The rows either side of the gap become ends of their own group. */
	.faq-item:has(> .faq-row[open]:not([data-faq-shut])) + .faq-item .faq-row {
		border-start-start-radius: var(--faq-radius);
		border-start-end-radius: var(--faq-radius);
	}

	.faq-item:has(+ .faq-item > .faq-row[open]:not([data-faq-shut])) .faq-row {
		border-end-start-radius: var(--faq-radius);
		border-end-end-radius: var(--faq-radius);
	}

	/*
	 * A hairline is for two rows that touch. Either side of the gap they no
	 * longer do, so it fades rather than being removed — a rule that vanished
	 * on the first frame would read as a flicker against a 420ms move.
	 */
	.faq-item:has(> .faq-row[open]:not([data-faq-shut])) .faq-row,
	.faq-item:has(> .faq-row[open]:not([data-faq-shut])) + .faq-item .faq-row {
		border-block-start-color: transparent;
	}

	/* ---- the summary --------------------------------------------------- */

	.faq-summary {
		display: flex;
		align-items: center;
		gap: var(--faq-row-gap);
		min-block-size: var(--faq-touch);
		padding-block: var(--faq-row-pad-block);
		padding-inline: var(--faq-row-pad-inline);
		cursor: pointer;
		/* Both spellings are needed: the pseudo-element is what actually
		   removes the marker in WebKit, the property everywhere else. */
		list-style: none;
	}

	.faq-summary::-webkit-details-marker {
		display: none;
	}

	.faq-summary:focus-visible {
		outline: 2.67px solid var(--faq-accent);
		outline-offset: 2px;
	}

	.faq-num {
		flex: 0 0 auto;
		display: grid;
		place-items: center;
		inline-size: var(--faq-badge);
		block-size: var(--faq-badge);
		border-radius: 50%;
		background: var(--faq-accent-soft);
		color: var(--faq-accent);
		font-family: Georgia, "Times New Roman", Times, serif;
		font-size: var(--faq-badge-size);
		font-weight: 500;
		line-height: 1;
		font-variant-numeric: tabular-nums;
	}

	.faq-question {
		flex: 1 1 0;
		min-width: 0;
		font-size: var(--faq-question-size);
		font-weight: 600;
		line-height: 1.4;
		color: var(--faq-ink);
		transition: color var(--faq-close-duration) var(--faq-close-ease);
	}

	.faq-summary:hover .faq-question,
	.faq-summary:focus-visible .faq-question {
		color: var(--faq-accent);
	}

	.faq-chevron {
		flex: 0 0 auto;
		display: grid;
		place-items: center;
		inline-size: 1.5em;
		block-size: 1.5em;
	}

	.faq-chevron::after {
		content: "";
		inline-size: 10px;
		block-size: 10px;
		border-inline-end: 2px solid var(--faq-accent);
		border-block-end: 2px solid var(--faq-accent);
		/* The nudge is optical, not decorative: a chevron pointing down carries
		   its weight low, so it has to sit high to look centred, and the other
		   way round once it turns over. */
		margin-block-start: -4px;
		transform: rotate(45deg);
		transition: transform var(--faq-close-duration) var(--faq-close-ease);
	}

	.faq-row[open]:not([data-faq-shut]) .faq-chevron::after {
		margin-block-start: 4px;
		transform: rotate(-135deg);
		transition-duration: var(--faq-open-duration);
		transition-timing-function: var(--faq-open-ease);
	}

	/* ---- the panel ----------------------------------------------------- */

	.faq-panel {
		display: grid;
		grid-template-rows: 0fr;
		transition: grid-template-rows var(--faq-close-duration) var(--faq-close-ease);
	}

	.faq-panel-inner {
		overflow: hidden;
	}

	.faq-row[open]:not([data-faq-shut]) .faq-panel {
		grid-template-rows: 1fr;
		transition-duration: var(--faq-open-duration);
		transition-timing-function: var(--faq-open-ease);
	}

	.faq-answer {
		max-width: 44rem;
		margin: 0;
		padding-inline: var(--faq-row-pad-inline);
		padding-inline-start: var(--faq-answer-indent);
		padding-block-end: 1.5rem;
		font-size: var(--faq-answer-size);
		line-height: 1.65;
		color: var(--faq-muted);
		/*
		 * The text fades rather than appearing with the box. Held back for a
		 * third of the open so the card has visibly started moving first,
		 * which is what stops the answer looking like it was always there.
		 */
		opacity: 0;
		transition: opacity var(--faq-close-duration) var(--faq-close-ease);
	}

	.faq-row[open]:not([data-faq-shut]) .faq-answer {
		opacity: 1;
		transition:
			opacity calc(var(--faq-open-duration) * 0.6) var(--faq-open-ease)
			calc(var(--faq-open-duration) * 0.35);
	}

	.faq-a {
		color: var(--faq-accent);
		text-decoration: underline;
		text-decoration-thickness: 1px;
		text-underline-offset: 3.2px;
	}

	.faq-a:hover {
		text-decoration-thickness: 2px;
	}

	.faq-a:focus-visible {
		outline: 2.67px solid var(--faq-accent);
		outline-offset: 2px;
		border-radius: 2px;
	}

	/* ---- the footer link ----------------------------------------------- */

	.faq-more {
		margin: 2rem 0 0;
		text-align: center;
	}

	.faq-link {
		display: inline-flex;
		align-items: baseline;
		gap: 0.4em;
		font-size: var(--faq-answer-size);
		font-weight: 500;
		line-height: 1.65;
		color: var(--faq-accent);
		text-decoration: none;
	}

	.faq-link:hover {
		text-decoration: underline;
		text-decoration-thickness: 1px;
		text-underline-offset: 3.2px;
	}

	.faq-link:focus-visible {
		outline: 2.67px solid var(--faq-accent);
		outline-offset: 3px;
		border-radius: 2px;
	}

	.faq-arrow {
		transition: translate var(--faq-close-duration) var(--faq-close-ease);
	}

	.faq-link:hover .faq-arrow {
		translate: 0.2em 0;
	}

	/* ---- above the narrow end ------------------------------------------ */

	@container (min-width: 480px) {
		/*
		 * Declared on the inner element, not on the root. The root IS the
		 * query container, and a container query never matches the container
		 * itself — only its descendants — so the same block written one level
		 * up silently does nothing. These inherit from here to every consumer.
		 */
		.faq-inner {
			--faq-row-pad-inline: 1.5rem;
			--faq-row-gap: 1.5rem;
			/*
			 * The answer lines up under the question, not under the badge.
			 * Derived from the two measurements either side of it so it stays
			 * aligned when the padding or the badge changes — the reference
			 * resolves its own badge term against the answer's font size
			 * instead of the badge's, and lands 2.2px to the right of its
			 * question as a result.
			 */
			--faq-answer-indent: calc(var(--faq-row-pad-inline) * 2 + var(--faq-badge));
		}
	}

	/* ---- preferences --------------------------------------------------- */

	@media (prefers-reduced-motion: reduce) {
		/*
		 * The two tokens, not the transitions themselves.
		 *
		 * Every duration and delay in this file is one of these or a calc on
		 * one, so collapsing them here reaches all of it — including the
		 * answer's delay, which is a fraction of the open duration. Written as
		 * `transition-duration` on a list of selectors instead, it would lose
		 * to the open-state rules below, which are more specific and come
		 * later: the row would still take 420ms with the preference on.
		 *
		 * Every one of these rests at a visible value, so switching the
		 * animation off leaves the component correct rather than blank: the
		 * row is open or shut, the answer legible or gone with it, the chevron
		 * turned or not.
		 */
		.faq-accordion {
			--faq-open-duration: 0.01ms;
			--faq-close-duration: 0.01ms;
		}
	}

	@media (forced-colors: active) {
		/* The card, the hairline and the lift are all colour and shadow, none
		   of which survive here — so the rows get a real border instead. */
		.faq-row {
			border: 1px solid CanvasText;
		}

		.faq-item + .faq-item .faq-row {
			border-block-start: 1px solid CanvasText;
		}

		.faq-num {
			border: 1px solid CanvasText;
		}
	}
</style>

===== END src/components/FaqAccordion.astro =====

===== FILE (required): src/components/ContactSplitForm.astro =====
---
/**
 * Contact split form
 *
 * A contact section split down the middle: a heading, a line of reach-us copy
 * and a real form on one side, a full-bleed image on the other. Below the
 * two-column breakpoint the image drops out entirely and the form takes the
 * page.
 *
 * Static: no script at all. Every control is a native form element, so
 * validation, keyboard behaviour and submission are the browser's, and the
 * whole thing works with JavaScript switched off.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont.
 * Every colour and measurement is a `--csf-*` custom property on the root.
 * The image is the only file it reaches for, and it is a prop.
 */

export interface ContactCountry {
	/** Shown in the closed select — "US", "GB". */
	code: string;
	/** Submitted as the value. Usually the dial code. */
	value: string;
	/** The option's full text. Falls back to `code`. */
	label?: string;
}

export interface ContactLabels {
	firstName?: string;
	lastName?: string;
	email?: string;
	phone?: string;
	country?: string;
	message?: string;
}

export interface ContactPlaceholders {
	firstName?: string;
	lastName?: string;
	email?: string;
	phone?: string;
	message?: string;
}

interface Props {
	/** The section heading. Unset: none. */
	heading?: string;
	/** Heading level, so it fits the page outline. */
	headingLevel?: "h1" | "h2" | "h3";
	/** The words before the email address. `""` drops the whole line. */
	ledeText?: string;
	/** The address in that line. `""` leaves the text with no link. */
	email?: string;
	/**
	 * Where the form posts. Left unset the form has no action, which means the
	 * browser submits to the current URL — fine for a demo, wrong for a site.
	 * Point it at a handler.
	 */
	action?: string;
	/** `post` unless you have a reason. */
	method?: "get" | "post";
	/** Added to the <form>, for handlers that route on it. */
	formName?: string;
	/** The submit button. */
	submitLabel?: string;
	/** Visible labels for the five fields. */
	labels?: ContactLabels;
	/** Placeholder text. Placeholders are hints, never labels. */
	placeholders?: ContactPlaceholders;
	/** The checkbox group's legend. `""` drops the group. */
	servicesLegend?: string;
	/** The checkboxes. Each becomes a `service` value on submit. Unset or `[]`: no group. */
	services?: string[];
	/** Show the phone row at all. */
	showPhone?: boolean;
	/**
	 * The dial-code select. `[]` renders the phone field without one.
	 *
	 * Option text defaults to `code value` — "US +1" — because a native
	 * select shows the SELECTED option's text when closed, so whatever is in
	 * the list is also what sits in the field. The reference shows a bare
	 * "US" there and full country names in its list, which only a custom
	 * listbox can do, and a custom listbox is a keyboard widget to build and
	 * maintain. Pass `label` for anything else.
	 */
	countries?: ContactCountry[];
	/**
	 * The image beside the form. Empty by default, which is the centred
	 * single-column form; pass one and the section becomes the two-column
	 * split it was measured from.
	 */
	image?: string;
	/**
	 * The image's aspect ratio, as a CSS `aspect-ratio` value. The default
	 * gives it a shape of its own and centres it in the column; `"auto"`
	 * restores the reference's behaviour, where the image is full-bleed and
	 * takes whatever height the form column happens to be.
	 */
	imageRatio?: string;
	/**
	 * Description of the image. Left empty it is marked decorative, which is
	 * right for a mood shot beside a form — it carries no information the
	 * copy does not.
	 */
	imageAlt?: string;
	/** The section surface. */
	background?: string;
	/** Heading and input text. */
	ink?: string;
	/** Lede, placeholders, legend. */
	mutedInk?: string;
	/** Field labels. */
	labelInk?: string;
	/** Input rings, checkbox edges, the whole hairline system. */
	borderColor?: string;
	/** Button fill, link colour, focus ring, required marks. */
	accent?: string;
	/** Text on the button. */
	accentInk?: string;
	/** Where the form column stops widening. */
	maxWidth?: string;
	/** Added to the root. */
	class?: string;
	/** Root element id. Also seeds every generated id. */
	id?: string;
}

const {
	/* No default heading or service list: both describe a particular business. The demo passes them. */
	heading = "",
	headingLevel = "h2",
	ledeText = "You can reach us any time at",
	/* No default address: an email is a fact about a real business. The demo passes one. */
	email = "",
	action,
	method = "post",
	formName,
	submitLabel = "Start the conversation",
	labels = {},
	placeholders = {},
	servicesLegend = "What do you need?",
	services = [],
	showPhone = true,
	countries = [
		{ code: "US", value: "+1" },
		{ code: "CA", value: "+1" },
		{ code: "GB", value: "+44" },
		{ code: "IE", value: "+353" },
		{ code: "AU", value: "+61" },
		{ code: "NZ", value: "+64" },
		{ code: "DE", value: "+49" },
		{ code: "FR", value: "+33" },
		{ code: "ES", value: "+34" },
		{ code: "MX", value: "+52" },
	],
	image = "",
	imageRatio = "5 / 4",
	imageAlt = "",
	background = "#ffffff",
	ink = "#101828",
	mutedInk = "#535862",
	labelInk = "#414651",
	borderColor = "#d5d7da",
	accent = "#7f56d9",
	accentInk = "#ffffff",
	maxWidth = "480px",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/*
 * Every control needs an id its label can point at, and two of these on one
 * page must not collide. Derived from `id` when the caller gives one, random
 * otherwise — an index-derived id would collide on the second instance.
 */
const uid = id ?? `csf-${Math.random().toString(36).slice(2, 9)}`;
/* `uid` can arrive from the `id` prop, which is caller data, and it goes into
   id and `for` attributes. Anything that is not an identifier character is
   stripped before it gets there. */
const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, "") || "csf";
const fid = (name: string) => `${safeUid}-${name}`;

const L: Required<ContactLabels> = {
	firstName: "First name",
	lastName: "Last name",
	email: "Email",
	phone: "Phone number",
	country: "Country dialling code",
	message: "Message",
	...labels,
};

const P: ContactPlaceholders = {
	firstName: "First name",
	lastName: "Last name",
	email: "you@company.com",
	phone: "(000) 000-0000",
	message: "Tell us what you are working on…",
	...placeholders,
};

/* Only schemes that resolve to an image. The src often comes from a content
   record rather than from the person writing the template. */
const SAFE_SRC = /^(?:https?:|data:image\/|[#/.])/i;
const safeSrc = (value?: string) =>
	value && SAFE_SRC.test(value.trim()) ? value.trim() : undefined;

const picture = safeSrc(image);
const mail = email?.trim();
const serviceList = (services ?? []).filter(Boolean);
const dialCodes = (countries ?? []).filter((c) => c?.code && c?.value);

const style = [
	`--csf-bg:${background}`,
	`--csf-ink:${ink}`,
	`--csf-muted:${mutedInk}`,
	`--csf-label:${labelInk}`,
	`--csf-border:${borderColor}`,
	`--csf-accent:${accent}`,
	`--csf-accent-ink:${accentInk}`,
	`--csf-max:${maxWidth}`,
	`--csf-image-ratio:${imageRatio}`,
].join(";");
---

<section
	class:list={["contact-split-form", className]}
	id={id}
	style={style}
	data-contact-split-form
	data-has-image={picture ? "" : undefined}
>
	<div class="csf-pane">
		<div class="csf-inner">
			{heading && <Heading class="csf-heading">{heading}</Heading>}

			{/* The lede leads into the address, so it renders only with one: no invented contact line. */}
			{mail && (
				<p class="csf-lede">
					{ledeText}{" "}
					{mail && <a class="csf-mail" href={`mailto:${mail}`}>{mail}</a>}
				</p>
			)}

			<form class="csf-form" action={action} method={method} name={formName}>
				<div class="csf-row">
					<div class="csf-field">
						<label class="csf-label" for={fid("first")}>
							{L.firstName}
							{/* `required` on the input is what a screen reader announces.
							    The mark is for sighted readers, so it is hidden from the
							    tree rather than read out as "star". */}
							<span class="csf-req" aria-hidden="true">*</span>
						</label>
						<div class="csf-control">
							<input
								class="csf-input"
								type="text"
								id={fid("first")}
								name="firstName"
								autocomplete="given-name"
								placeholder={P.firstName}
								required
							/>
						</div>
					</div>

					<div class="csf-field">
						<label class="csf-label" for={fid("last")}>
							{L.lastName}
							<span class="csf-req" aria-hidden="true">*</span>
						</label>
						<div class="csf-control">
							<input
								class="csf-input"
								type="text"
								id={fid("last")}
								name="lastName"
								autocomplete="family-name"
								placeholder={P.lastName}
								required
							/>
						</div>
					</div>
				</div>

				<div class="csf-field">
					<label class="csf-label" for={fid("email")}>
						{L.email}
						<span class="csf-req" aria-hidden="true">*</span>
					</label>
					<div class="csf-control">
						<input
							class="csf-input"
							type="email"
							id={fid("email")}
							name="email"
							autocomplete="email"
							placeholder={P.email}
							required
						/>
					</div>
				</div>

				{showPhone && (
					<div class="csf-field">
						<label class="csf-label" for={fid("phone")}>{L.phone}</label>
						<div class="csf-control csf-control--split">
							{dialCodes.length > 0 && (
								<>
									{/* A real label, hidden. The select sits inside the
									    phone field's box but it is its own control and
									    needs its own name. */}
									<label class="csf-a11y" for={fid("dial")}>{L.country}</label>
									<select class="csf-select" id={fid("dial")} name="countryCode">
										{dialCodes.map((c) => (
											<option value={c.value}>{c.label ?? `${c.code} ${c.value}`}</option>
										))}
									</select>
								</>
							)}
							<input
								class="csf-input"
								type="tel"
								id={fid("phone")}
								name="phone"
								autocomplete="tel"
								placeholder={P.phone}
							/>
						</div>
					</div>
				)}

				<div class="csf-field">
					<label class="csf-label" for={fid("message")}>
						{L.message}
						<span class="csf-req" aria-hidden="true">*</span>
					</label>
					<div class="csf-control csf-control--area">
						<textarea
							class="csf-input csf-textarea"
							id={fid("message")}
							name="message"
							rows="5"
							placeholder={P.message}
							required></textarea>
					</div>
				</div>

				{servicesLegend && serviceList.length > 0 && (
					/* A real fieldset with a real legend, which is what groups
					   checkboxes for assistive technology. */
					<fieldset class="csf-fieldset">
						<legend class="csf-legend">{servicesLegend}</legend>
						<div class="csf-services">
							{serviceList.map((service, i) => (
								<div class="csf-check">
									<input
										class="csf-checkbox"
										type="checkbox"
										id={fid(`service-${i}`)}
										name="services"
										value={service}
									/>
									<label class="csf-check-label" for={fid(`service-${i}`)}>{service}</label>
								</div>
							))}
						</div>
					</fieldset>
				)}

				<button class="csf-submit" type="submit">{submitLabel}</button>
			</form>
		</div>
	</div>

	{picture && (
		<div class="csf-media">
			<img
				class="csf-image"
				src={picture}
				alt={imageAlt}
				width="1200"
				height="960"
				loading="lazy"
				decoding="async"
			/>
		</div>
	)}
</section>

<style>
	.contact-split-form {
		--csf-pad-block: 64px;
		--csf-pad-inline: 16px;
		/*
		 * Size, leading and tracking all step together, because the reference
		 * changes all three: 30px on 38px with normal tracking below the
		 * breakpoint, 36px on 44px at -0.02em above it. One ratio cannot
		 * express both — measured 43.92px against its 44px when it was tried.
		 */
		--csf-heading-size: 30px;
		--csf-heading-line: 38px;
		--csf-heading-track: normal;
		--csf-radius: 8px;
		--csf-control-h: 44px;
		--csf-duration: 150ms;
		--csf-ease: cubic-bezier(0.4, 0, 0.2, 1);

		box-sizing: border-box;
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		inline-size: 100%;
		background: var(--csf-bg);
		color: var(--csf-ink);
		/* A system stack on purpose: a component in this library fetches
		   nothing, so it can never be waiting on a webfont. */
		font-family:
			system-ui,
			-apple-system,
			"Segoe UI",
			Roboto,
			Helvetica,
			Arial,
			sans-serif;
	}

	.contact-split-form *,
	.contact-split-form *::before,
	.contact-split-form *::after {
		box-sizing: border-box;
	}

	/*
	 * A scoped reset, and it earns its place. Form controls do not inherit
	 * type from their ancestors, and a page that styles `input`, `label`,
	 * `fieldset` or `button` globally is the normal case rather than the
	 * exception — a contact section lands in exactly those pages.
	 *
	 * `:where()` keeps this at the specificity of the root alone, so the rules
	 * underneath still win on source order while a host's element selector
	 * loses. The `:global()` is load-bearing: Astro cannot put its scope
	 * attribute inside `:where()`, so without it the compiler emits a second
	 * attribute beside it and the whole thing outranks the class rules.
	 */
	.contact-split-form
		:global(:where(h1, h2, h3, p, a, label, legend, input, select, textarea, button, fieldset, form, div)) {
		margin: 0;
		padding: 0;
		border: 0;
		font: inherit;
		color: inherit;
		letter-spacing: inherit;
		background: none;
	}

	.csf-pane {
		padding: var(--csf-pad-block) var(--csf-pad-inline);
	}

	/*
	 * Centred in the pane, which is what makes the imageless form sit in the
	 * middle of the page rather than hard against the left. With an image the
	 * pane is one half of the grid and this centres the form inside that half,
	 * which is where the reference puts it.
	 */
	.csf-inner {
		margin-inline: auto;
	}

	/* ---- header --------------------------------------------------------- */

	.csf-heading {
		font-size: var(--csf-heading-size);
		line-height: var(--csf-heading-line);
		font-weight: 600;
		letter-spacing: var(--csf-heading-track);
		color: var(--csf-ink);
		text-wrap: balance;
	}

	.csf-lede {
		margin-block-start: 20px;
		font-size: 20px;
		line-height: 1.5;
		color: var(--csf-muted);
	}

	.csf-mail {
		/* A step darker than the button. The reference uses two brand values —
		   #6941c6 for the link against #7f56d9 for the fill — rather than one,
		   and a link at the fill's lightness is thin against white. Derived
		   rather than exposed, so there is still only one accent to set. */
		color: color-mix(in srgb, var(--csf-accent) 84%, #000);
		font-weight: 500;
		text-decoration: none;
	}

	.csf-mail:hover {
		text-decoration: underline;
	}

	/* ---- form ----------------------------------------------------------- */

	.csf-form {
		display: flex;
		flex-direction: column;
		gap: 32px;
		margin-block-start: 48px;
	}

	.csf-row {
		display: flex;
		flex-direction: column;
		gap: 24px 32px;
	}

	.csf-field {
		display: flex;
		flex-direction: column;
		gap: 6px;
		/* So a field in a flex row can shrink instead of forcing the row open. */
		min-inline-size: 0;
		flex: 1 1 0;
	}

	.csf-label {
		font-size: 14px;
		line-height: 1.43;
		font-weight: 500;
		color: var(--csf-label);
	}

	.csf-req {
		color: var(--csf-accent);
	}

	/*
	 * The ring is an inset shadow rather than a border, which is how the
	 * reference draws it, and it keeps the control's box exactly 44px however
	 * thick the ring gets.
	 */
	.csf-control {
		display: flex;
		align-items: stretch;
		min-block-size: var(--csf-control-h);
		border-radius: var(--csf-radius);
		background: #fff;
		box-shadow:
			inset 0 0 0 1px var(--csf-border),
			0 1px 2px rgb(16 24 40 / 0.05);
		transition: box-shadow var(--csf-duration) var(--csf-ease);
	}

	.csf-control--area {
		min-block-size: 0;
	}

	.csf-input {
		inline-size: 100%;
		min-inline-size: 0;
		padding: 10px 14px;
		border-radius: inherit;
		background: transparent;
		color: var(--csf-ink);
		/* 16px, and not for looks: anything smaller makes iOS Safari zoom the
		   page when the field takes focus. */
		font-size: 16px;
		line-height: 1.5;
	}

	.csf-input::placeholder {
		color: var(--csf-muted);
		opacity: 1;
	}

	/*
	 * The focus indicator is an outline rather than the reference's inset
	 * shadow. It looks the same drawn one pixel inside the box, and unlike a
	 * shadow it survives a forced palette — where a shadow simply is not
	 * painted and the field would have no focus indicator at all.
	 */
	.csf-input:focus,
	.csf-select:focus {
		outline: none;
	}

	.csf-control:focus-within {
		box-shadow:
			inset 0 0 0 1px var(--csf-accent),
			0 1px 2px rgb(16 24 40 / 0.05);
	}

	.csf-input:focus-visible,
	.csf-select:focus-visible {
		outline: 2px solid var(--csf-accent);
		outline-offset: -2px;
		border-radius: inherit;
	}

	.csf-textarea {
		min-block-size: 144px;
		/* Vertical only: a horizontal drag can push a textarea past its column
		   and take the page's width with it. */
		resize: vertical;
	}

	/* ---- the phone composite -------------------------------------------- */

	.csf-select {
		flex: 0 0 auto;
		padding: 10px 8px 10px 14px;
		border-start-start-radius: var(--csf-radius);
		border-end-start-radius: var(--csf-radius);
		color: var(--csf-ink);
		font-size: 16px;
		line-height: 1.5;
		cursor: pointer;
		/* The UA arrow is kept. Drawing a replacement means hiding the real
		   one, and a select that has lost its affordance is worse than one
		   whose arrow is the platform's. */
	}

	.csf-control--split .csf-input {
		border-start-start-radius: 0;
		border-end-start-radius: 0;
	}

	/* ---- services -------------------------------------------------------- */

	.csf-fieldset {
		min-inline-size: 0;
	}

	.csf-legend {
		/* `display: contents` would drop it from the tree. A legend positioned
		   normally is what names the group. */
		margin-block-end: 16px;
		font-size: 14px;
		line-height: 1.43;
		font-weight: 500;
		color: var(--csf-label);
	}

	.csf-services {
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		gap: 16px 64px;
	}

	.csf-check {
		display: flex;
		align-items: center;
		gap: 12px;
		min-inline-size: 0;
	}

	/*
	 * A real checkbox, restyled rather than replaced. `appearance: none` keeps
	 * the element — its focus behaviour, its label association, its place in
	 * the form — and only takes away the platform's painting. The tick is a
	 * background image rather than a pseudo-element, because a pseudo-element
	 * on a replaced element is not reliable across engines.
	 */
	.csf-checkbox {
		flex: 0 0 auto;
		appearance: none;
		-webkit-appearance: none;
		inline-size: 20px;
		block-size: 20px;
		border-radius: 6px;
		background: #fff;
		box-shadow: inset 0 0 0 1px var(--csf-border);
		cursor: pointer;
		transition:
			background-color var(--csf-duration) var(--csf-ease),
			box-shadow var(--csf-duration) var(--csf-ease);
	}

	.csf-checkbox:checked {
		background-color: var(--csf-accent);
		background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m3.5 8.5 3 3 6-6'/%3E%3C/svg%3E");
		background-repeat: no-repeat;
		background-position: center;
		background-size: 16px 16px;
		box-shadow: inset 0 0 0 1px var(--csf-accent);
	}

	.csf-checkbox:focus-visible {
		outline: 2px solid var(--csf-accent);
		outline-offset: 2px;
	}

	.csf-check-label {
		font-size: 16px;
		line-height: 1.5;
		color: var(--csf-ink);
		cursor: pointer;
	}

	/* ---- submit ---------------------------------------------------------- */

	.csf-submit {
		inline-size: 100%;
		padding: 12px 18px;
		border-radius: var(--csf-radius);
		background: var(--csf-accent);
		color: var(--csf-accent-ink);
		font-size: 16px;
		line-height: 1.5;
		font-weight: 600;
		cursor: pointer;
		box-shadow:
			inset 0 0 0 1px rgb(16 24 40 / 0.18),
			inset 0 -2px 0 rgb(16 24 40 / 0.05),
			0 1px 2px rgb(16 24 40 / 0.05);
		transition: background-color var(--csf-duration) var(--csf-ease);
	}

	.csf-submit:hover {
		background: color-mix(in srgb, var(--csf-accent) 88%, #000);
	}

	.csf-submit:focus-visible {
		outline: 2px solid var(--csf-accent);
		outline-offset: 2px;
	}

	/* ---- media ----------------------------------------------------------- */

	/*
	 * Not rendered at all below the two-column breakpoint rather than hidden
	 * with CSS: the reference sets `display: none`, which still downloads the
	 * image on some engines. Here the column simply does not exist in the
	 * grid, and the <img> is lazy, so a phone pays nothing for it.
	 */
	.csf-media {
		display: none;
		position: relative;
		overflow: hidden;
		/*
		 * A shape of its own rather than whatever height the form column ends
		 * up being. `aspect-ratio` needs the box to stop stretching first —
		 * a grid item defaults to `stretch`, which sets a definite height and
		 * makes the ratio a no-op — so the item is centred in its track and
		 * takes its height from its width.
		 *
		 * `auto` restores the reference: the image fills the column edge to
		 * edge, however tall the form makes it.
		 */
		align-self: center;
		aspect-ratio: var(--csf-image-ratio);
	}

	.csf-image {
		display: block;
		inline-size: 100%;
		block-size: 100%;
		object-fit: cover;
	}

	/* Read, not seen. */
	.csf-a11y {
		position: absolute;
		inline-size: 1px;
		block-size: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
		border: 0;
	}

	/* ---- responsive ------------------------------------------------------ */

	/*
	 * Mobile first, so each boundary lands on the same pixel as the
	 * reference's — 640, 768 and 1024. Written as `max-width` they would each
	 * miss a viewport reporting a fractional width just under the round
	 * number, which is a mistake this library has already made once.
	 */
	@media (min-width: 640px) {
		.csf-row {
			flex-direction: row;
		}

		.csf-services {
			grid-template-columns: repeat(2, minmax(0, 1fr));
		}
	}

	@media (min-width: 768px) {
		.contact-split-form {
			--csf-pad-block: 96px;
			--csf-pad-inline: 32px;
			--csf-heading-size: 36px;
			--csf-heading-line: 44px;
			--csf-heading-track: -0.02em;
		}

		.csf-inner {
			max-inline-size: var(--csf-max);
		}
	}

	@media (min-width: 1024px) {
		/*
		 * Only when there is an image. Declared unconditionally, a section
		 * with no second child still reserved half the width for it and the
		 * form centred inside the left half rather than the page — measured
		 * 120px of space on its left against 840px on its right at 1440.
		 */
		.contact-split-form[data-has-image] {
			grid-template-columns: repeat(2, minmax(0, 1fr));
		}

		.csf-media {
			display: block;
		}
	}

	/* ---- preferences ----------------------------------------------------- */

	@media (prefers-reduced-motion: reduce) {
		/* One token, because every transition in the file resolves through it.
		   Nothing rests at zero opacity or zero size, so removing the motion
		   leaves the section correct rather than blank. */
		.contact-split-form {
			--csf-duration: 0.01ms;
		}
	}

	@media (forced-colors: active) {
		/*
		 * Every ring here is an inset shadow, and a shadow is not painted at
		 * all under a forced palette — without this the fields, the checkboxes
		 * and the button would have no visible edge whatsoever.
		 */
		.csf-control,
		.csf-checkbox,
		.csf-submit {
			border: 1px solid CanvasText;
			box-shadow: none;
		}

		.csf-control:focus-within {
			border-color: Highlight;
		}

		.csf-checkbox:checked {
			background-color: Highlight;
		}

		.csf-submit {
			background: ButtonFace;
			color: ButtonText;
		}
	}
</style>

===== END src/components/ContactSplitForm.astro =====

===== FILE (required): src/components/PanelFooter.astro =====
---
/**
 * PanelFooter — a site footer drawn as a rounded dark panel inset from the
 * page edge, with a brand column, link groups, a contact block with a call to
 * action, and a legal bar underneath.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * no bundled assets — and no JavaScript: every behaviour here is a CSS state.
 *
 * Measured behaviour:
 *   frame         12px inset around a 20px-radius panel, ring + drop + inset shadow
 *   columns       1 up to 768, 2 to 1024, then a 12-track row: 4 / 2 / 2 / 4
 *   link rows     8px apart, 14px/20px
 *   group titles  12px bold uppercase, 0.05em tracking
 *   cta           pill, 12px bold uppercase, recolours over 200ms
 *   links         recolour over 200ms; there is no underline and no motion
 *   legal bar     stacked and centred below 768, spread into a row above it
 *
 * The reference footer carries no entrance animation and no scroll effect —
 * only hover colour — which is why this file ships no script.
 */

export interface FooterLink {
	label: string;
	/**
	 * Destination. Omit it — or pass an empty string — and the item renders as
	 * plain, non-interactive text instead of a link that goes nowhere.
	 */
	href?: string;
	/** Open in a new tab, with the matching rel. */
	external?: boolean;
}

export interface FooterGroup {
	title: string;
	links: FooterLink[];
}

export interface FooterSocial {
	/** Accessible name, e.g. "Mastodon". Rendered as the link's label. */
	label: string;
	/** Destination. Omit it and the glyph is drawn as inert decoration. */
	href?: string;
	/**
	 * `d` attribute for a 24x24 stroked icon. Omit to use a neutral glyph.
	 * Supply your own so no icon set has to travel with the file.
	 */
	path?: string;
	external?: boolean;
}

export interface FooterContact {
	/** Column heading, e.g. "Studio & contact". */
	title: string;
	/** Small tracked line under the heading, e.g. a registered name. */
	legalName?: string;
	/** Postal address. Plain text unless `addressHref` is given. */
	address?: string;
	/** Makes the address a link — a map URL, for example. */
	addressHref?: string;
	/** Displayed phone number. */
	phone?: string;
	/** Dial target. Defaults to `phone` stripped of spacing. */
	phoneHref?: string;
	email?: string;
}

export interface FooterCta {
	label: string;
	href: string;
	external?: boolean;
}

interface Props {
	/** Brand name, set as a wordmark. Empty string hides it. */
	brand?: string;
	/** Where the wordmark links to. */
	brandHref?: string;
	/** Sentence under the wordmark. */
	description?: string;
	/** Small icon links under the description. */
	social?: FooterSocial[];
	/** The link columns between the brand and the contact block. */
	groups?: FooterGroup[];
	/** The contact column. Pass `null` to leave it out. */
	contact?: FooterContact | null;
	/** Button at the foot of the contact column. `null` leaves it out. */
	cta?: FooterCta | null;
	/** Left-hand line in the legal bar. */
	copyright?: string;
	/** Centre line in the legal bar. Empty string hides it. */
	note?: string;
	/** Right-hand links in the legal bar. */
	legal?: FooterLink[];
	/** Page colour behind the inset panel. */
	surround?: string;
	/** Top of the panel gradient. */
	panelTop?: string;
	/** Bottom of the panel gradient. */
	panelBottom?: string;
	/** Headings and wordmark colour. */
	ink?: string;
	/** Body and link colour. */
	body?: string;
	/** Button background. */
	accent?: string;
	/** Button background on hover. */
	accentHover?: string;
	/** Link colour on hover. */
	linkHover?: string;
	/** Corner radius of the panel. */
	radius?: string;
	class?: string;
	id?: string;
}

/*
 * Nothing about the business is assumed. Brand, description, links, contact
 * details, call to action, copyright and note all default to nothing, and a
 * block with nothing in it is not drawn — pass nothing at all and the footer
 * renders nothing. The catalog's demo studio lives in the registry's preview
 * props, not here, so it can never ship as someone's live contact details.
 */
const {
	brand,
	brandHref,
	description,
	social = [],
	groups = [],
	contact = null,
	/* A call to action has to go somewhere, so `href` stays required. */
	cta = null,
	copyright,
	note,
	legal = [],
	surround = "#eef0f2",
	panelTop = "#232120",
	panelBottom = "#151417",
	ink = "#ffffff",
	body = "#c9cdd4",
	accent = "#b0431e",
	accentHover = "#cf5a2e",
	linkHover = "#f08a5f",
	radius = "20px",
	class: className,
	id,
} = Astro.props;

const hasBrand = Boolean(brand || description || social.length > 0);
const hasTop = hasBrand || groups.length > 0 || Boolean(contact);
const hasBottom = Boolean(copyright || note || legal.length > 0);

/* 4 tracks for the brand, 2 for each group, 4 for contact — the desktop row. */
const columns = (hasBrand ? 4 : 0) + groups.length * 2 + (contact ? 4 : 0);

/**
 * A destination only counts if it is a non-empty string. Anything else — an
 * omitted prop, an empty string, a stray "#" — renders as text, so nothing in
 * this footer can look like a link while doing nothing.
 */
const isDestination = (href?: string) => {
	const trimmed = href?.trim();
	return !!trimmed && trimmed !== "#";
};

const relFor = (external?: boolean) => (external ? "noopener noreferrer" : undefined);
const targetFor = (external?: boolean) => (external ? "_blank" : undefined);
/* A dialable href from a displayed number: keep a leading + and the digits. */
const telHref = (number: string) => {
	const trimmed = number.trim();
	const digits = trimmed.replace(/\D/g, "");
	return digits ? `tel:${trimmed.startsWith("+") ? "+" : ""}${digits}` : "";
};
/* A call link only with somewhere to dial: words in `phone` are not a number. */
const callHref = contact?.phone?.trim()
	? isDestination(contact.phoneHref)
		? contact.phoneHref!.trim()
		: telHref(contact.phone)
	: "";
const hasContactRows = Boolean(contact && (contact.address || contact.phone || contact.email));

const NEUTRAL_GLYPH = "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z";
---

{(hasTop || hasBottom) && (
<footer
	class:list={["frame", className]}
	id={id}
	style={`--pf-surround:${surround};--pf-panel-top:${panelTop};--pf-panel-bottom:${panelBottom};--pf-ink:${ink};--pf-body:${body};--pf-accent:${accent};--pf-accent-hover:${accentHover};--pf-link-hover:${linkHover};--pf-radius:${radius};--pf-columns:${columns};`}
>
	<div class="panel">
		{hasTop && (
		<div class="top">
			<div class="grid">
				{hasBrand && (
				<div class="col brand-col">
					{
						brand &&
							(isDestination(brandHref) ? (
								<a class="wordmark" href={brandHref}>
									{brand}
								</a>
							) : (
								<p class="wordmark wordmark-static">{brand}</p>
							))
					}
					{description && <p class="description">{description}</p>}
					{
						social.length > 0 && (
							<ul class="social">
								{social.map((item) => {
									const Glyph = isDestination(item.href) ? "a" : "span";
									return (
										<li>
											<Glyph
												class:list={[
													"social-link",
													{ "social-static": !isDestination(item.href) },
												]}
												href={isDestination(item.href) ? item.href : undefined}
												target={isDestination(item.href) ? targetFor(item.external) : undefined}
												rel={isDestination(item.href) ? relFor(item.external) : undefined}
											>
												<svg
													viewBox="0 0 24 24"
													width="18"
													height="18"
													fill="none"
													stroke="currentColor"
													stroke-width="1.5"
													stroke-linecap="round"
													stroke-linejoin="round"
													aria-hidden="true"
												>
													<path d={item.path ?? NEUTRAL_GLYPH} />
												</svg>
												<span class="sr-only">{item.label}</span>
											</Glyph>
										</li>
									);
								})}
							</ul>
						)
					}
				</div>
				)}

				{
					groups.map((group) => (
						<div class="col group-col">
							<h3 class="col-title">{group.title}</h3>
							<ul class="links">
								{group.links.map((link) =>
									isDestination(link.href) ? (
										<li>
											<a
												class="link"
												href={link.href}
												target={targetFor(link.external)}
												rel={relFor(link.external)}
											>
												{link.label}
											</a>
										</li>
									) : (
										/* Same <li> wrapper as the link branch, so a group with no
										   destinations keeps the exact row rhythm of one that has them. */
										<li>
											<span class="link link-static">{link.label}</span>
										</li>
									),
								)}
							</ul>
						</div>
					))
				}

				{
					contact && (
						<div class="col contact-col">
							<div class="contact-head">
								<h3 class="col-title">{contact.title}</h3>
								{contact.legalName && <p class="legal-name">{contact.legalName}</p>}
							</div>

							{hasContactRows && (
							<ul class="contact-list">
								{contact.address && (
									<li class="contact-row">
										<svg
											class="contact-icon"
											viewBox="0 0 24 24"
											width="16"
											height="16"
											fill="none"
											stroke="currentColor"
											stroke-width="1.5"
											stroke-linecap="round"
											stroke-linejoin="round"
											aria-hidden="true"
										>
											<path d="M12 21s-6.5-5.3-6.5-10.5a6.5 6.5 0 1 1 13 0C18.5 15.7 12 21 12 21Z" />
											<circle cx="12" cy="10.5" r="2.4" />
										</svg>
										{contact.addressHref ? (
											<a class="link" href={contact.addressHref}>
												{contact.address}
											</a>
										) : (
											<span class="contact-text">{contact.address}</span>
										)}
									</li>
								)}

								{contact.phone && (
									<li class="contact-row">
										<svg
											class="contact-icon"
											viewBox="0 0 24 24"
											width="16"
											height="16"
											fill="none"
											stroke="currentColor"
											stroke-width="1.5"
											stroke-linecap="round"
											stroke-linejoin="round"
											aria-hidden="true"
										>
											<path d="M6.5 4h3l1.5 4-2 1.5a12 12 0 0 0 5.5 5.5L16 13l4 1.5v3a2 2 0 0 1-2.2 2A16 16 0 0 1 4.5 6.2 2 2 0 0 1 6.5 4Z" />
										</svg>
										{callHref ? (
											<a class="link" href={callHref}>
												{contact.phone}
											</a>
										) : (
											<span class="contact-text">{contact.phone}</span>
										)}
									</li>
								)}

								{contact.email && (
									<li class="contact-row">
										<svg
											class="contact-icon"
											viewBox="0 0 24 24"
											width="16"
											height="16"
											fill="none"
											stroke="currentColor"
											stroke-width="1.5"
											stroke-linecap="round"
											stroke-linejoin="round"
											aria-hidden="true"
										>
											<rect x="3" y="5.5" width="18" height="13" rx="2" />
											<path d="m3.5 7 8.5 6 8.5-6" />
										</svg>
										<a class="link" href={`mailto:${contact.email}`}>
											{contact.email}
										</a>
									</li>
								)}
							</ul>
							)}

							{cta && (
								<a
									class="cta"
									href={cta.href}
									target={targetFor(cta.external)}
									rel={relFor(cta.external)}
								>
									{cta.label}
								</a>
							)}
						</div>
					)
				}
			</div>
		</div>
		)}

		{hasBottom && (
		<div class="bottom">
			<div class="bottom-inner">
				{copyright && <p class="copyright">{copyright}</p>}
				{note && <p class="note">{note}</p>}
				{
					legal.length > 0 && (
						<ul class="legal">
							{legal.map((link) =>
								isDestination(link.href) ? (
									<li>
										<a
											class="link legal-link"
											href={link.href}
											target={targetFor(link.external)}
											rel={relFor(link.external)}
										>
											{link.label}
										</a>
									</li>
								) : (
									<li>
										<span class="link legal-link link-static">{link.label}</span>
									</li>
								),
							)}
						</ul>
					)
				}
			</div>
		</div>
		)}
	</div>
</footer>
)}

<style>
	.frame {
		--pf-muted: color-mix(in srgb, var(--pf-ink) 50%, transparent);
		/* Dimmer than body text so it reads as a label, but still held above
		   the 4.5:1 contrast floor rather than dropped to decoration. */
		--pf-static: color-mix(in srgb, var(--pf-ink) 64%, transparent);
		--pf-hairline: color-mix(in srgb, var(--pf-ink) 10%, transparent);

		--pf-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
			"Helvetica Neue", Arial, sans-serif;

		--pf-title: 0.75rem;
		--pf-link: 0.875rem;
		--pf-small: 0.75rem;

		box-sizing: border-box;
		display: block;
		padding: 12px;
		background: var(--pf-surround);
		font-family: var(--pf-font);
	}

	.frame :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.panel {
		display: flex;
		flex-direction: column;
		/* Keeps the panel from collapsing when the columns are short. */
		min-height: min(30rem, 40vh);
		overflow: hidden;
		border-radius: var(--pf-radius);
		background: linear-gradient(var(--pf-panel-top) 0%, var(--pf-panel-bottom) 100%);
		color: var(--pf-body);
		box-shadow:
			0 0 0 1px rgb(0 0 0 / 0.05),
			0 8px 30px rgb(0 0 0 / 0.12),
			inset 0 1px 0 rgb(255 255 255 / 0.1);
	}

	.top {
		flex: 1;
		padding: 40px 24px 0;
	}

	/* ---------- columns ---------- */

	.grid {
		display: grid;
		grid-template-columns: 1fr;
		gap: 36px;
		margin-bottom: 40px;
	}

	.col {
		display: flex;
		flex-direction: column;
		gap: 14px;
		min-width: 0;
	}

	.wordmark {
		align-self: flex-start;
		font-size: 1.25rem;
		font-weight: 650;
		letter-spacing: -0.02em;
		color: var(--pf-ink);
		text-decoration: none;
	}

	.description {
		max-width: 24rem;
		margin: 0;
		font-size: var(--pf-link);
		line-height: 1.375;
		text-wrap: pretty;
	}

	.social {
		display: flex;
		align-items: center;
		gap: 16px;
		list-style: none;
		margin: 0;
		padding: 4px 0 0;
	}

	.social-link {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		color: var(--pf-body);
		text-decoration: none;
		transition: color 200ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	.col-title {
		margin: 0;
		font-size: var(--pf-title);
		font-weight: 700;
		line-height: 1.33;
		letter-spacing: 0.05em;
		text-transform: uppercase;
		color: var(--pf-ink);
	}

	.links {
		display: flex;
		flex-direction: column;
		gap: 8px;
		list-style: none;
		margin: 0;
		padding: 0;
	}

	.link {
		font-size: var(--pf-link);
		line-height: 1.43;
		color: var(--pf-body);
		text-decoration: none;
		transition: color 200ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	/*
	 * An item with no destination. It is a <li> or <span> rather than an <a>,
	 * so it is already out of the tab order and unclickable; these rules make
	 * that legible by dimming it and dropping the text cursor, so it reads as
	 * a label rather than as a link that is failing to work.
	 */
	.link-static,
	.social-static {
		color: var(--pf-static);
		cursor: default;
		transition: none;
	}

	/* A brand name that is not a link is a heading, not a broken link, so it
	   keeps its full weight and colour — it only stops being clickable. */
	.wordmark-static {
		margin: 0;
		cursor: default;
		transition: none;
	}

	/* ---------- contact ---------- */

	.contact-head {
		display: flex;
		flex-direction: column;
		gap: 8px;
	}

	.legal-name {
		margin: 0;
		font-size: 0.6875rem;
		font-weight: 500;
		letter-spacing: 0.12em;
		text-transform: uppercase;
		color: var(--pf-muted);
	}

	.contact-list {
		display: flex;
		flex-direction: column;
		gap: 14px;
		list-style: none;
		margin: 0;
		padding: 0;
	}

	.contact-row {
		display: flex;
		gap: 10px;
		align-items: flex-start;
	}

	.contact-icon {
		flex-shrink: 0;
		/* Sits on the first line's optical centre rather than its top edge. */
		margin-top: 0.15em;
		color: var(--pf-muted);
	}

	.contact-text {
		font-size: var(--pf-link);
		line-height: 1.43;
		text-wrap: pretty;
	}

	.cta {
		align-self: flex-start;
		margin-top: 2px;
		padding: 8px 20px;
		border-radius: 999px;
		background: var(--pf-accent);
		color: #fff;
		font-size: var(--pf-title);
		font-weight: 700;
		line-height: 1.33;
		letter-spacing: 0.05em;
		text-transform: uppercase;
		text-decoration: none;
		transition: background-color 200ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	/* ---------- legal bar ---------- */

	.bottom {
		margin-top: auto;
		border-top: 1px solid var(--pf-hairline);
		padding: 24px;
	}

	.bottom-inner {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 12px;
	}

	.copyright,
	.note {
		margin: 0;
		font-size: var(--pf-small);
		line-height: 1.67;
		text-align: center;
	}

	.note {
		color: var(--pf-muted);
	}

	.legal {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		justify-content: center;
		gap: 12px;
		list-style: none;
		margin: 0;
		padding: 0;
	}

	/* ---------- states ----------
	 * The reference leaves the browser's default focus ring in place, which is
	 * a dark outline on a dark panel and effectively invisible. This draws its
	 * own so a keyboard reader can see where they are.
	 */

	.link:focus-visible,
	.social-link:focus-visible,
	.wordmark:focus-visible,
	.cta:focus-visible {
		outline: 2px solid var(--pf-ink);
		outline-offset: 3px;
		border-radius: 2px;
	}

	.sr-only {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
		border: 0;
	}

	/* Hover is a pointer affordance. On a touch screen :hover sticks after the
	   finger lifts, which would strand a link in its hover colour. */
	@media (hover: hover) and (pointer: fine) {
		.link:hover,
		.social-link:hover {
			color: var(--pf-link-hover);
		}

		.wordmark:hover {
			color: var(--pf-link-hover);
		}

		.cta:hover {
			background: var(--pf-accent-hover);
		}
	}

	/* ---------- breakpoints ---------- */

	@media (min-width: 640px) {
		.frame {
			--pf-small: 0.875rem;
		}

		.top {
			padding: 48px 40px 0;
		}

		.bottom {
			padding: 32px 40px;
		}
	}

	@media (min-width: 768px) {
		.grid {
			grid-template-columns: repeat(2, minmax(0, 1fr));
			gap: 44px;
		}

		.bottom-inner {
			flex-direction: row;
			justify-content: space-between;
			gap: 16px;
		}

		.copyright {
			text-align: left;
		}

		.legal {
			justify-content: flex-end;
			gap: 16px;
		}
	}

	@media (min-width: 1024px) {
		.top {
			padding: 56px 56px 0;
		}

		/*
		 * One row of narrow tracks rather than four equal columns: the brand
		 * and contact blocks take four each and every link group takes two, so
		 * the wide blocks stay wide however many groups there are.
		 */
		.grid {
			grid-template-columns: repeat(var(--pf-columns), minmax(0, 1fr));
			gap: 28px;
			margin-bottom: 48px;
		}

		.brand-col,
		.contact-col {
			grid-column: span 4;
		}

		.group-col {
			grid-column: span 2;
		}

		.bottom {
			padding: 32px 56px 40px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.link,
		.social-link,
		.wordmark,
		.cta {
			transition: none;
		}
	}
</style>

===== END src/components/PanelFooter.astro =====

===== FILE (supporting): src/components/StickyMegaMenuHeader.nav-data.ts =====
/**
 * StickyMegaMenuHeader — navigation data, worked example.
 *
 * The component ships no menu of its own (Step 14): pass `items` or the header
 * renders no navigation. This file is a worked example of that data. What it is for is the thing that actually goes
 * wrong when a header is installed somewhere real: the destination project has
 * its own routes, and someone has to map them into this shape. Doing that in a
 * data file rather than inline in a layout means the routes can be read,
 * reviewed and changed without touching markup.
 *
 * Copy it next to the component, edit every label and href to match the
 * destination's real routes, and pass it in:
 *
 *   ---
 *   import Header from "../components/StickyMegaMenuHeader.astro";
 *   import { primaryNav } from "../components/StickyMegaMenuHeader.nav-data";
 *   ---
 *   <Header items={primaryNav} currentPath={Astro.url.pathname} />
 *
 * The types come from the component itself, so this file cannot drift from
 * what the component accepts: rename a field there and `astro check` fails
 * here.
 */
import type { NavItem } from "./StickyMegaMenuHeader.astro";

/**
 * Every route in the example tree is a placeholder. Replace them with routes
 * that exist in the destination project — a header pointing at four 404s is
 * worse than no header, because it looks finished.
 *
 * Shape rules the component enforces:
 *   - an item has `href` OR `groups`, never both;
 *   - an item with `groups` renders as a mega-panel trigger (a real `<button>`);
 *   - an item with `href` renders as a plain link;
 *   - `heading` on a group may be `""` to render a column with no heading.
 */
export const primaryNav: NavItem[] = [
	{
		label: "Product",
		groups: [
			{
				heading: "By need",
				links: [
					{ label: "Overview", href: "/product", description: "What it does, in one page." },
					{ label: "Integrations", href: "/product/integrations", description: "What it talks to." },
					{ label: "Security", href: "/product/security", description: "How access is handled." },
				],
			},
			{
				heading: "By team",
				links: [
					{ label: "Engineering", href: "/teams/engineering" },
					{ label: "Design", href: "/teams/design" },
					{ label: "Operations", href: "/teams/operations" },
				],
			},
		],
	},
	{
		label: "Resources",
		groups: [
			{
				heading: "Learn",
				links: [
					{ label: "Guides", href: "/guides", description: "Short, practical walkthroughs." },
					{ label: "Changelog", href: "/changelog", description: "What shipped, and when." },
				],
			},
		],
	},
	// A plain destination: no `groups`, so no panel and no trigger button.
	{ label: "Pricing", href: "/pricing" },
	{ label: "Contact", href: "/contact" },
];

===== END src/components/StickyMegaMenuHeader.nav-data.ts =====

--- SECTIONS ---
- Sticky mega menu header (production-ready in the library): https://astro.baysixmedia.com/components/sticky-mega-menu-header/
- Framed hero (not enrolled in verification): https://astro.baysixmedia.com/components/framed-hero/
- Staggered logo cloud (enrolled, not yet production-ready): https://astro.baysixmedia.com/components/staggered-logo-cloud/
- Icon feature grid (not enrolled in verification): https://astro.baysixmedia.com/components/icon-feature-grid/
- Process flow grid (production-ready in the library): https://astro.baysixmedia.com/components/process-flow-grid/
- Testimonial roster (production-ready in the library): https://astro.baysixmedia.com/components/testimonial-roster/
- FAQ accordion (production-ready in the library): https://astro.baysixmedia.com/components/faq-accordion/
- Contact split form (production-ready in the library): https://astro.baysixmedia.com/components/contact-split-form/
- Panel footer (production-ready in the library): https://astro.baysixmedia.com/components/panel-footer/

Each component's README — its full prop reference — is on its library page above, and is not repeated here. The library verified each component on its own. Nobody has verified this page in your project: test the assembled page yourself.

What's on the page

9 sections, top to bottom. Each is an ordinary library component — open one for its props, its own preview and its verification record.

  1. HeaderSticky mega menu headerproduction-ready
  2. SectionFramed heronot enrolled
  3. SectionStaggered logo cloudin verification
  4. SectionIcon feature gridnot enrolled
  5. SectionProcess flow gridproduction-ready
  6. SectionTestimonial rosterproduction-ready
  7. SectionFAQ accordionproduction-ready
  8. SectionContact split formproduction-ready
  9. FooterPanel footerproduction-ready

16 required files · page file src/pages/local-service-homepage.astro · no npm packages