Component library
Back to catalog

Testimonial roster

A testimonial with the person attached: a portrait card carrying a headline figure beside the quote, over a roster of everyone with something to say. Pick a face and the pair changes together.

testimonialcarouseltabsquoteportraitsocial proof

Open preview in a new tab

Use this component

Three ways to get Testimonial roster into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 5 files appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Testimonial roster from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/testimonial-roster/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. 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.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

Install kit

The 5 files this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Testimonial roster"
Library page: https://astro.baysixmedia.com/components/testimonial-roster/
Files: 5 required, 1 supporting

Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.

===== 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 (supporting): src/components/TestimonialRoster.md =====
# Testimonial roster

A testimonial that comes with the person attached: a portrait card carrying a
headline figure beside the quote, and under them a roster of everyone who has
something to say. Pick a face and the pair changes together.

Catalog page: `/components/testimonial-roster`

## Copying it into another Astro project

Copy **`TestimonialRoster.astro`**, plus whatever portraits you use.

| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | none |
| Astro built-ins (`astro:assets`, …) | none |
| Global stylesheet | none — every value is a `--tro-*` property on the root |
| Webfonts | none — system stack, including the mono face |
| Icon sets | none — the star and the arrows are inline `<svg>` |
| **Images** | **yes** — one portrait per person, passed as props |
| Network requests | only the portraits you point it at |
| Host requirement | Astro compiles `.astro` files |

The catalog demo passes four portraits at `/testimonial-roster/portrait-*.svg`.
They are this library's own artwork and they are **not** part of the
component — the component has no people, quotes or portraits of its own, and
renders nothing until you pass `items`.

### Exact steps

1. Copy `TestimonialRoster.astro` into your components folder.
2. Put your own portraits in `public/` (the demo SVGs in
   `public/testimonial-roster/` are only needed to reproduce the catalog demo).
3. Use it:

```astro
---
import TestimonialRoster from "../components/TestimonialRoster.astro";
---

<TestimonialRoster
  eyebrow="What our customers say"
  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.",
      image: "/img/ivy.jpg",
      stat: { value: "9h", label: "Given back to the team each week" },
    },
  ]}
/>
```

No client directive and no wrapper. The controller boots itself.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string` | `"In their own words"` | Small line over the headline. `""` drops it. |
| `headline` | `string` | `""` | The section heading. Omitted, there is none. |
| `headingLevel` | `"h2" \| "h3" \| "h4"` | `"h2"` | So it fits your page outline. |
| `items` | `TestimonialRosterEntry[]` | `[]` | The people. **None, and nothing renders at all** — not even the header. |
| `initialIndex` | `number` | `0` | Who is showing on arrival. Clamped into range. |
| `rosterLabel` | `string` | `"Choose a customer"` | Accessible name for the roster. |
| `background` | `string` | `"#ffffff"` | The page colour behind the cards. |
| `ink` | `string` | `"#20291f"` | Headline, quote, name. |
| `mutedInk` | `string` | `"#6d7569"` | Eyebrow, role, counter, company. |
| `paper` | `string` | `"#f2f3ed"` | The quote card's ground. |
| `accent` | `string` | `"#d8edaa"` | Stat card, badge tint, arrow hover. |
| `rule` | `string` | `"#dde1d5"` | Hairlines and arrow borders. |
| `maxWidth` | `string` | `"1120px"` | Where the **content** stops widening. |
| `class` | `string` | — | Added to the root. |
| `id` | `string` | — | Root id. Also seeds every generated id. |

### The types

```ts
interface TestimonialRosterEntry {
  name: string;              // also the roster label and accessible name
  role?: string;             // "Head of Operations"
  company?: string;          // shown in the roster and at the card's foot
  quote: string;             // the component draws the quotation marks
  rating?: number;           // 0–5; omit (or 0) and no stars are drawn
  image?: string;            // portrait for the big card
  imageAlt?: string;         // omit → decorative, which is right beside a name
  avatar?: string;           // roster thumbnail; falls back to `image`
  stat?: { value: string; label: string };
  badge?: string;            // the pill in the portrait's corner
}
```

A person with no `name` or no `quote` is dropped. An `image` that is not
`http:`, `https:`, `data:image/` or a path is dropped too — a roster tends to
be filled from a content record rather than typed by hand.

Stars appear only for a `rating` you pass. Leave it out and there are **no**
stars — the component never fills in a rating on a customer's behalf, so a
page generated from real quotes cannot publish review evidence nobody gave.
`rating: 0` also draws none rather than five empty ones, because a rating
nobody gave is not a rating of zero. The catalog demo's people (passed from
the registry, not built in) state `rating: 5` explicitly.

The component supplies no trust data of its own: no people, quotes, ratings,
figures, badges, portraits or headline. The demo cast, its "Customer story"
badges and its figures live in the catalog registry's demo `props`.

## Behaviour

- **It is a tab set.** The roster is `role="tablist"`, each person is a `tab`,
  each testimonial is a `tabpanel`. That is not decoration: it is what gets you
  arrow keys, Home and End for nothing, and it is what the roster looks like it
  should do.
- **Arrow keys** move between people, wrapping at both ends. **Home** and
  **End** jump to the first and last. Selection follows focus, which is the tab
  pattern's automatic activation.
- **Roving tabindex**: the roster is one tab stop, not one per person.
- **The prev/next arrows wrap** in both directions, so neither is ever a dead
  control. Clicking one moves focus to the equivalent arrow in the panel that
  replaced it — otherwise focus would land on `<body>`, because the button you
  clicked has just been hidden.
- **No autoplay.** Nothing moves unless somebody asks it to.
- On a narrow screen the roster is a horizontal scroller, and selecting a
  person scrolls them into view.

## The entrance

Two animations, both 450ms on `ease`:

| | From | To |
| :--- | :--- | :--- |
| `tro-reveal` (quote block, stat card) | `opacity: 0`, `translateY(12px)` | `opacity: 1`, no offset |
| `tro-photo-in` (portrait) | `opacity: 0.25`, `scale(1.035)` | `opacity: 1`, no scale |

They re-run without any class being added or removed: a panel goes from
`display: none` to displayed, and that restarts its animations from the
beginning. No forced reflow, no `requestAnimationFrame` dance.

## Without JavaScript

Nothing is hidden behind a script that has not run:

- All the testimonials are in the DOM and **visible**, as a horizontally
  scrolling strip with snap points.
- The strip is focusable and labelled, so it can be scrolled from the keyboard
  — a scroll container that cannot be focused cannot be scrolled. The
  controller gives that tab stop up on boot, when there is nothing left to
  scroll.
- Each panel shows its own `01 / 04`, so position is still legible.
- **The roster and the arrows are not rendered as dead controls.** They are
  revealed only once the controller claims the component. Four buttons that
  announce themselves as a tab set and switch nothing would be worse than not
  being there, since the panels are already reachable.

## Accessibility

- Full tab-set semantics with `aria-controls`, `aria-labelledby` and exactly
  one `aria-selected="true"` per instance.
- A polite live region announces "Testimonial 2 of 4, Name, Company" on every
  change. Deliberately short: selecting a tab does not move focus, so something
  has to say the panel changed — but repeating the whole quote would say it
  twice.
- Every control clears a 24px target; the arrows are 44px.
- The focus ring is 2px at 3px offset, on the classes directly rather than
  through `:where()`. See the trap below.
- `prefers-reduced-motion: reduce` replaces both entrance animations with a
  120ms **opacity-only** fade. The panel still changes and it is still legible
  that it changed; what goes is the travel and the scale. A hard cut between
  two blocks of text is harder to follow, not easier.
- `forced-colors: active` redraws the structure in border terms, since every
  tint here is colour on colour. The roster's inactive tabs come back to full
  opacity, because opacity is not a colour and a forced palette will not fix
  it; the selection is marked by `Highlight` on its rule instead.
- All caller text sets `overflow-wrap: anywhere`. Without it one unbreakable
  56-character word pushed the document 360px past a 390px viewport.

## Two traps this component walked into

Both are worth knowing if you edit it, because neither fails loudly.

**An element is never matched by its own container query.** A query container
resolves against its nearest *ancestor* container, so tokens set on the
container itself do nothing. Written that way first, the type never stepped
down: measured at 320px the quote was still 34px.

**`container-type: inline-size` queries the content box.** Padding on the
container makes every breakpoint fire early by twice the padding — and if the
padding itself changes at a breakpoint, the content box grows as the window
narrows and the layout can flip back and forth across one pixel. The padding
here is on `.tro-inner` for exactly that reason, and `maxWidth` grows by it so
the prop still means the width of the content.

## Relationship to the reference

Rebuilt from a live page by measurement. No markup, CSS or copy was taken from
it: the cast, the quotes, the statistics and all the artwork are original to
this library.

Measured against the reference at 1440px and matched exactly: the
`0.38fr / 0.62fr` columns at an 18px gap (418.75px / 683.25px), the 1120px
content width, the quote at 34px/41.48px with −0.85px of tracking, the 379×103
stat card, the 44px arrows and the 36px avatars. The card comes out 442px tall
against its 446px — the difference is line-box height in the mono face, since
the reference uses a webfont and this uses a system stack.

The motion is the reference's own, read off its running animations rather than
guessed: 450ms, `ease`, `translateY(12px)` and `scale(1.035)`.

Three deliberate departures:

1. Its roster is a group of `aria-pressed` toggle buttons. This is a real tab
   set, so arrow keys, Home and End work.
2. Its reduced-motion rule kills every animation and transition with
   `!important`. This keeps a short opacity fade.
3. It has no `overflow-wrap`, so it has the long-word overflow this one was
   measured for and fixed.

===== END src/components/TestimonialRoster.md =====

===== 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 =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
Verified 2026-09-23 at 1920, 1440, 1037, 1024, 834, 768, 430, 390, 360, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle, active state integrity, long content, loop integrity, clone inertness, loop transition continuity, click intent separation, pointer drag, touch swipe, autoplay pause, autoplay reduced motion.
Library source fingerprint: sha256:db575165fa8f5eb0e7b6c08eb790948521585ea59cc45d1a8da65c0336e6cc64 — MATCHES the files below.

--- NOT PROVEN ---
- Nothing outstanding in the library's own checks.

--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.

--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Testimonial roster” from my Astro Component Library:
https://astro.baysixmedia.com/components/testimonial-roster/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

Component readiness

Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.

Interactivity
Ships script
Profile
carousel
Installation files
5 required + 1 supporting
Verified
2026-09-23
Client navigation
End-to-end passed against a real ClientRouter
Source fingerprint
Matches the verified source
Production ready
Yes

Production-ready carousel

Every required check passed against the code below, and the recorded fingerprint still matches it.

Verified 2026-09-23 at 1920, 1440, 1037, 1024, 834, 768, 430, 390, 360, 320px

  • Full-page contextpass
  • Keyboardpass
  • No JavaScriptpass
  • Reduced motionpass
  • Two instancespass
  • No overflowpass
  • Declarations corroboratedpass
  • Lifecyclepass
  • Active state integritypass
  • Long contentpass
  • Loop integritypass
  • Clone inertnesspass
  • Loop transition continuitypass
  • Click intent separation — excused by declared capabilitiesnot-applicable
  • Pointer drag — excused by declared capabilitiesnot-applicable
  • Touch swipe — excused by declared capabilitiesnot-applicable
  • Autoplay pause — excused by declared capabilitiesnot-applicable
  • Autoplay reduced motion — excused by declared capabilitiesnot-applicable
28 verification notes
  • Fingerprint re-recorded 2026-09-24 (Step 13, content safety) after a no-behaviour content change, following the precedent above: the four built-in demo people and the headline became empty defaults and moved into the registry demo props; with no people the component renders nothing. Style and script blocks are byte-identical to the verified revision. Evidence for keeping this record: every preview, integration-test and template page was built before and after the change and compared — identical apart from per-build random ids — so each check below still describes what renders with the catalog's configuration. The new no-data behaviour is pinned by rendering tests in src/lib/content-safety.test.ts rather than by a fixture, and was checked in Chromium at 320, 390, 768 and 1440 (no overflow, no console errors, no duplicate ids, nothing rendered). No new full verification run was performed.
  • Fingerprint re-recorded 2026-09-24 after a frontmatter-only fix, without a new browser run. An omitted `rating` used to draw five stars, which put a rating nobody gave on a real testimonial; it now draws none, and the four built-in demo people state `rating: 5` explicitly. No markup, style or script changed. The evidence for keeping this record: the three fixture pages it was measured on (integration-test/component, client-nav and client-nav/destination for testimonial-roster) were built before and after the change and are byte-identical, so every check below still describes what renders. The new no-rating behaviour is pinned by src/lib/trust-evidence.test.ts rather than by a fixture.
  • Re-verified 2026-09-23 after a script-only fix. The controller kept the selected tab visible with scrollIntoView, which also scrolls the window, so a roster below the fold dragged the page down to itself on load - reproduced as scrollY 0 to ~2180 on a template page. It now sets the roster strip's own scrollLeft and touches nothing else. Measured after the fix: scrollY stays 0 on load on the local-service-homepage template at 1920, 1440, 1024, 834, 768, 430, 390, 360 and 320 with the roster as the sixth section, and on the generic fixture at 1037 and 390 - including instance B, which opens on the third person 2616px down and now scrolls only its own strip (to 187px) to show that tab.
  • Phone behaviour kept: at 430, 360 and 320 the roster is a scroller, and arrowing through every tab, End, and a pointer click on the last tab with the strip reset to 0 all leave the selected tab fully inside the strip, with zero window drift. At 768 and up the roster does not scroll and the strip is untouched.
  • Re-run against this revision: keyboard (arrows, End, roving tabindex, focus follows selection, live region text), multi-instance independence with zero duplicate ids, reduced motion (only the documented 120ms opacity fade runs on a switch), no-JavaScript (scripts blocked: controller not booted, all four panels in flow, roster hidden), zero page overflow at every width above, and zero console errors or warnings. Client navigation re-run by real link clicks over five transitions plus Back and Forward: sentinel survived, new root each time, the outgoing root detached, its ready flag dropped and its tabs inert, one activation moving exactly one step, and the component's own document listeners constant at two live (astro:page-load and astro:before-swap) with no growth.
  • Not re-measured this time: the full 16-width container-query ladder (752 and 432 breakpoints) and the hostile-content overflow pass below. No CSS rule changed in this revision - only one comment - so those results describe the same styles. The notes that follow are from the 2026-09-20 record.
  • Rebuilt from a live page by measurement, not by copying. Read at 1440px: 1120px of content, columns of 0.38fr and 0.62fr at an 18px gap (418.75px against 683.25px), a 36px card padding, the quote at 34px on 41.48px with -0.85px of tracking, a 379x103 stat card inset 20px, 44px arrows and 36px avatars. Every one of those matches the reference exactly.
  • One measured difference: the quote card comes out 442px tall against the reference's 446px. It is line-box height in the mono face - the reference loads Geist Mono and this uses a system stack, so the counter row and the attribution each sit a pixel or two shorter. Stated rather than forced, because closing it would mean hard-coding heights tuned to one font.
  • The motion is the reference's own, read off its running animations rather than inferred from frames: 450ms on ease, opacity 0 to 1 with translateY(12px) for the quote and the stat card, and opacity 0.25 to 1 with scale(1.035) for the portrait. Reproduced at the same durations and the same curve.
  • The recording shows a blank gap of 430 to 500ms between one testimonial leaving and the next arriving, and that gap is NOT reproduced, deliberately. Instrumenting the live page with its images cached shows the content swapping on the same frame as the click and the entrance starting immediately, delay 0. The gap in the recording is the reference's portraits loading over the network, not a designed pause. The video is the authority on movement and it was followed; this one thing it shows is an artifact of the capture, and that was established by measurement rather than assumed.
  • No autoplay, and the reference has none either - its recording looks timed only because someone was clicking. Verified by leaving the live page idle for six seconds with no change.
  • Built as a real tab set rather than the reference's group of aria-pressed toggles: role tablist, tab and tabpanel, aria-controls and aria-labelledby resolving on every one, exactly one aria-selected per instance, and a roving tabindex so the roster is one tab stop. Arrow keys, Home and End all verified, including wrapping at both ends.
  • Keyboard verified on the fixture: 18 controls, arrow keys moving and selecting together, Home and End jumping to the ends, and the focus ring computing as 2px solid at 3px of offset. The arrows move focus to the equivalent control in the panel that replaced the one they hid, so focus never lands on body.
  • Arrows wrap in both directions and are never disabled, matching the reference. Verified: next from the last returns to the first and previous from the first returns to the last, by click and by key.
  • No JavaScript: every testimonial is in the DOM and visible as a snap-scrolling strip, each panel showing its own counter. The strip is focusable and labelled so it can be scrolled from the keyboard, and the controller gives that tab stop up on boot. The roster and the arrows are revealed only once the controller claims the component, so a scriptless page has no dead controls and no tab set that announces itself and switches nothing.
  • Reduced motion replaces both entrance animations with a 120ms opacity-only fade - measured, the keyframes carry no transform at all - and collapses the control transitions. Everything settles at full opacity with no transform, and nothing is left stranded at zero opacity. The reference kills all motion with a blanket !important rule; this keeps the cross-fade, because the objection is to movement rather than to a change in opacity.
  • Forced colours: every tint here is colour on colour, so the structure is redrawn in border terms. Measured under emulation - cards, portrait and stat card take CanvasText borders, the badge drops its blur for Canvas on CanvasText, the roster's inactive tabs return to full opacity because opacity is not a colour a forced palette can fix, the selection is marked by Highlight on its rule, and the focus ring takes Highlight. No text renders the colour of the ground it sits on.
  • Responsive by container query on the ROOT rather than on the inner wrapper, and the padding is on the inner wrapper rather than the root. Both are fixes. An element is never matched by its own container query, so with the container on the wrapper every token the breakpoints set on it silently did nothing - measured at 320px, the quote was still 34px and the headline still 38px. And container-type: inline-size queries the CONTENT box, so padding on the container fires every breakpoint early by twice the padding, and because the padding itself changes at a breakpoint the content box would have grown as the window narrowed and could have flipped the layout across a single pixel.
  • The ladder as it now measures: two columns down to 752px, then one column with the padding at 20px and the type stepping to 27.2 and 30.4, then a second step at 432px to 22 and 26. The reference switches somewhere between 600 and 768, so 752 sits inside its bracket.
  • Long content: an unbreakable 56-character word in the quote pushed the document 360px past a 390px viewport, measured. Fixed with overflow-wrap: anywhere on every caller-supplied string - anywhere rather than break-word, because only anywhere also shrinks the min-content size that forces a track open. Re-measured at 1440, 768, 390 and 320 with hostile values in every field: zero page overflow, nothing escaping the card, and the badge given a max-inline-size so it wraps at its inset instead of running under the card's clip.
  • Client-side navigation proven end to end against a real ClientRouter, not by dispatching events. Three transitions: before-swap, after-swap and page-load fire in order, the document is never reloaded, the root is a new node each time, the controller re-boots, and one click still moves exactly one step.
  • Lifecycle observed rather than inferred. At astro:before-swap the outgoing instance drops its ready flag, un-hides every panel and hands back the tabindex, role and label that boot took off the strip - so the DOM it leaves is the DOM the server sent. The detached node is then inert: clicking its tabs changes nothing, which is the AbortController having released the listeners. Across three transitions the component added zero new listeners to window or document, because it attaches none there except the two module-level lifecycle hooks.
  • Generic fixture: two instances starting on different people stay independent - driving one never moves the other - with no duplicate ids across the page, no console errors or warnings, and no external requests of any kind. The only requests the component makes are its four portraits.
  • Declarations corroborated against the rendered fixture: 18 focusable controls against hasFocusableContent, three running animations against hasMotion, the id prop reaching the root against acceptsIdProp, and the ready flag on both roots against interactivity: scripted.
  • This entry carries its own driver contract rather than the carousel profile's. The profile's names the first carousel's data-ccc-* attributes, which match nothing here, and publishing them would hand the fixture and the lifecycle probe selectors that resolve to nothing. The optional override was added for this and changes no existing entry. Verified on the fixture: the published contract's root and state attributes both resolve against this component.
  • cloneInertness passes vacuously and is recorded as a pass rather than excused. There are no runtime clones at all - the panels are all server-rendered and the controller only toggles hidden - so there is nothing that could carry a duplicate tab stop or id, which the duplicate-id count on the two-instance fixture confirms at zero.
  • The cast, the quotes, the statistics, the headline and all four portraits are original to this library. The reference's wording and imagery are not reproduced.
  • Chromium only. No WebKit or Gecko build was available in this session and no claim is made about either. The features that gap matters most for here are container queries, :has()-free CSS, and backdrop-filter on the badge, which degrades to a plain tint where it is unsupported.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/TestimonialRoster.astro

This component is not one file on its own

  • assetsCopy these across too — they are referenced by URL, so nothing in the source above points at them: public/testimonial-roster/portrait-1.svg, public/testimonial-roster/portrait-2.svg, public/testimonial-roster/portrait-3.svg, public/testimonial-roster/portrait-4.svg. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * 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>