Component library
Back to catalog

Committed about split

A two-column about section: a rounded photo beside an orange eyebrow, heading, paragraph, three » points and an orange pill. mediaSide swaps the sides; a videoUrl turns the photo into a video thumbnail with a play button and a modal player.

aboutsplitvideodialogappear

Open preview in a new tab

Use this component

Three ways to get Committed about split 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 3 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 Committed about split from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/committed-about-split/

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 3 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: "Committed about split"
Library page: https://astro.baysixmedia.com/components/committed-about-split/
Files: 3 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/CommittedAboutSplit.astro =====
---
/**
 * CommittedAboutSplit
 *
 * A two-column about section: a rounded photo on one side, and on the other an
 * orange eyebrow with a short rule, a heading, a paragraph, three "»" points
 * and an orange pill link. `mediaSide` puts the photo left or right.
 *
 * Give it a `videoUrl` and the photo becomes a video thumbnail: a navy wash,
 * a round play button in the middle, and a modal <dialog> that plays the
 * video (a YouTube/Vimeo embed in an iframe, or an .mp4/.webm file in a
 * <video>). Without JavaScript the play button is a plain link to the video.
 *
 * On scroll the photo slides in from its outer side (40px) and the copy from
 * the other side.
 *
 * Reads --tg-* design tokens from any ancestor, with fallbacks.
 */

export interface CommittedAboutSplitLink {
	label: string;
	href: string;
}

export interface CommittedAboutSplitProps {
	/** Which side the photo sits on at 810px and up. On a phone the DOM order holds. */
	mediaSide?: "left" | "right";
	/** The photo, or the video thumbnail. No `src` renders the labelled placeholder. */
	image?: { src?: string; alt?: string };
	/** Turns the photo into a video thumbnail with a play button and a modal player. */
	videoUrl?: string;
	/** Accessible name for the play button and the player. Omitted: "Play video" / "Video". */
	videoTitle?: string;
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	text?: string;
	/** The "»" points. `[]` drops the list. */
	points?: string[];
	/** The orange pill. `null` (the default) drops it. */
	cta?: CommittedAboutSplitLink | null;
	/** Section surface. The home variant is #ecf0f1, the about variant white. */
	background?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends CommittedAboutSplitProps {}

/*
 * The heading, copy, points, button, photo and video title are the caller's:
 * nothing about a business is filled in here. "About us" is a generic label
 * and never renders the section on its own. The catalog demo passes its
 * sample content through the registry's `props`.
 */
const {
	mediaSide = "left",
	image: imageProp,
	videoUrl,
	videoTitle = "",
	eyebrow = "About us",
	heading = "",
	headingLevel = "h2",
	text = "",
	points = [],
	cta = null,
	background = "#ecf0f1",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=DM+Sans:wght@400;500;600&display=swap",
	class: className,
	id,
} = Astro.props;

const hasVideo = Boolean(videoUrl && videoUrl.trim());
/* A photo given without alt is decorative. No photo: the neutral placeholder. */
const image = { src: imageProp?.src ?? "", alt: imageProp?.alt ?? "" };
const pointList = (points ?? []).filter((p) => typeof p === "string" && p.trim());
const title = videoTitle.trim();
const playLabel = title ? `Play video: ${title}` : "Play video";

/* Copy worth a column; the generic eyebrow alone is not. */
const hasCopy = Boolean(heading || text || pointList.length > 0 || cta);
/* Nothing to say → nothing rendered. */
const hasContent = hasCopy || hasVideo;

const Heading = headingLevel;
const uid = id ?? `cas-${Math.random().toString(36).slice(2, 8)}`;
const dialogId = `${uid}-player`;
const titleId = `${uid}-player-title`;

const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const video = hasVideo ? safeHref(videoUrl!) : "";
const isFile = /\.(mp4|webm|ogg)(\?|#|$)/i.test(video);

const IMAGE_SPEC = hasVideo ? "1:1 · 1128 × 1124 px" : "~0.95:1 · 1152 × 1214 px";
const IMAGE_HINT = hasVideo ? "Video thumbnail, square" : "Photo, near-square portrait";
---

{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<section
	class:list={["cas", className]}
	id={id}
	style={`--cas-bg: ${background};`}
	data-cas-root
	data-media-side={mediaSide}
	data-video={hasVideo ? "" : undefined}
>
	<div class="cas-inner">
		{
			mediaSide === "right" && hasCopy && (
				<div class="cas-copy" data-cas-copy>
					{eyebrow && (
						<p class="cas-eyebrow">
							{eyebrow}
							<span class="cas-rule" aria-hidden="true" />
						</p>
					)}
					{heading && <Heading class="cas-heading">{heading}</Heading>}
					{text && <p class="cas-text">{text}</p>}
					{pointList.length > 0 && (
						<ul class="cas-points" role="list">
							{pointList.map((point) => (
								<li class="cas-point">
									<svg class="cas-chev" viewBox="0 0 18 14" width="18" height="14" aria-hidden="true" focusable="false">
										<path d="M2 1.5 7.5 7 2 12.5M9.5 1.5 15 7l-5.5 5.5" />
									</svg>
									<span>{point}</span>
								</li>
							))}
						</ul>
					)}
					{cta && (
						<a class="cas-btn" href={safeHref(cta.href)}>
							{cta.label}
							<svg class="cas-btn-arrow" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
								<path d="M2 10 10 2M3.5 2H10v6.5" />
							</svg>
						</a>
					)}
				</div>
			)
		}

		<div class="cas-media" data-cas-media>
			{
				image.src ? (
					<img
						src={image.src}
						alt={image.alt}
						width={hasVideo ? 1128 : 1152}
						height={hasVideo ? 1124 : 1214}
						loading="lazy"
						decoding="async"
					/>
				) : (
					<div class="cas-ph" role="img" aria-label={`Image placeholder: ${IMAGE_HINT}`}>
						<div class="cas-ph-text" aria-hidden="true">
							<span class="cas-ph-label">Image</span>
							<span class="cas-ph-hint">{IMAGE_HINT}</span>
							<span class="cas-ph-spec">{IMAGE_SPEC}</span>
						</div>
					</div>
				)
			}
			{
				hasVideo && (
					<>
						<span class="cas-wash" aria-hidden="true" />
						<a
							class="cas-play"
							href={video}
							target="_blank"
							rel="noopener"
							aria-label={playLabel}
							aria-haspopup="dialog"
							data-cas-play
						>
							<span class="cas-play-halo" aria-hidden="true" />
							<span class="cas-play-disc" aria-hidden="true">
								<svg viewBox="0 0 12 14" width="12" height="14" focusable="false">
									<path d="M1 1.2v11.6a.6.6 0 0 0 .9.5l9.6-5.8a.6.6 0 0 0 0-1L1.9.7a.6.6 0 0 0-.9.5Z" />
								</svg>
							</span>
						</a>
					</>
				)
			}
		</div>

		{
			mediaSide !== "right" && hasCopy && (
				<div class="cas-copy" data-cas-copy>
					{eyebrow && (
						<p class="cas-eyebrow">
							{eyebrow}
							<span class="cas-rule" aria-hidden="true" />
						</p>
					)}
					{heading && <Heading class="cas-heading">{heading}</Heading>}
					{text && <p class="cas-text">{text}</p>}
					{pointList.length > 0 && (
						<ul class="cas-points" role="list">
							{pointList.map((point) => (
								<li class="cas-point">
									<svg class="cas-chev" viewBox="0 0 18 14" width="18" height="14" aria-hidden="true" focusable="false">
										<path d="M2 1.5 7.5 7 2 12.5M9.5 1.5 15 7l-5.5 5.5" />
									</svg>
									<span>{point}</span>
								</li>
							))}
						</ul>
					)}
					{cta && (
						<a class="cas-btn" href={safeHref(cta.href)}>
							{cta.label}
							<svg class="cas-btn-arrow" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
								<path d="M2 10 10 2M3.5 2H10v6.5" />
							</svg>
						</a>
					)}
				</div>
			)
		}
	</div>

	{
		hasVideo && (
			<dialog class="cas-dialog" id={dialogId} aria-labelledby={titleId} data-cas-dialog data-src={video} data-kind={isFile ? "file" : "embed"}>
				<div class="cas-dialog-bar">
					<p class="cas-dialog-title" id={titleId}>{title || "Video"}</p>
					<button type="button" class="cas-close" data-cas-close aria-label="Close video">
						<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false">
							<path d="M6 6l12 12M18 6 6 18" />
						</svg>
					</button>
				</div>
				<div class="cas-frame" data-cas-frame />
			</dialog>
		)
	}
</section>
)}

<style>
	.cas {
		--cas-navy: var(--tg-navy, #020e28);
		--cas-orange: var(--tg-orange, #fd5523);
		--cas-muted: var(--tg-muted, #61697d);
		--cas-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
		--cas-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
		--cas-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
		--cas-radius: 30px;

		box-sizing: border-box;
		padding: 80px 20px;
		background: var(--cas-bg, #ecf0f1);
		color: var(--cas-navy);
		overflow-x: clip;
	}

	.cas[data-video] {
		--cas-radius: 20px;
	}

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

	.cas-inner {
		display: flex;
		flex-direction: column;
		gap: 30px;
		max-width: 1253px;
		margin: 0 auto;
	}

	/* ---- media ---- */
	.cas-media {
		position: relative;
		width: 100%;
		aspect-ratio: 350 / 278;
		border-radius: var(--cas-radius);
		overflow: hidden;
		background: #dfe5e7;
	}

	.cas[data-video] .cas-media {
		aspect-ratio: 1;
	}

	.cas-media img {
		position: absolute;
		inset: 0;
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	.cas-wash {
		position: absolute;
		inset: 0;
		background: rgb(2 14 40 / 0.5);
	}

	.cas-play {
		position: absolute;
		top: 50%;
		left: 50%;
		display: grid;
		place-items: center;
		width: 64px;
		height: 64px;
		margin: -32px 0 0 -32px;
		border-radius: 50%;
		cursor: pointer;
	}

	.cas-play-halo {
		position: absolute;
		inset: 0;
		border-radius: 50%;
		background: #fff;
		opacity: 0.3;
	}

	.cas-play-disc {
		position: relative;
		display: grid;
		place-items: center;
		width: 40px;
		height: 40px;
		padding-left: 2px;
		border-radius: 50%;
		background: #fff;
		transition: transform 0.3s var(--cas-ease);
	}

	.cas-play-disc svg {
		fill: var(--cas-orange);
	}

	.cas-play:hover .cas-play-disc {
		transform: scale(1.05);
	}

	.cas-play:focus-visible {
		outline: 3px solid #fff;
		outline-offset: 4px;
	}

	.cas-ph {
		display: grid;
		place-items: center;
		width: 100%;
		height: 100%;
		padding: 16px;
		text-align: center;
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		color: rgb(2 14 40 / 0.62);
	}

	.cas-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 8px;
	}

	.cas-ph-label {
		font: 600 12px/1 var(--cas-body);
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.cas-ph-hint {
		max-width: 28ch;
		font: 500 14px/1.4 var(--cas-body);
	}

	.cas-ph-spec {
		font: 400 12px/1.2 var(--cas-body);
	}

	/* ---- copy ---- */
	.cas-copy {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		min-width: 0;
	}

	.cas .cas-eyebrow {
		display: inline-flex;
		align-items: center;
		gap: 12px;
		margin: 0 0 16px;
		font: 600 16px/1 var(--cas-display);
		text-transform: uppercase;
		color: var(--cas-orange);
	}

	.cas-rule {
		display: none;
		width: 40px;
		height: 1px;
		background: var(--cas-orange);
	}

	.cas-heading {
		margin: 0;
		font: 600 32px/1.2 var(--cas-display);
		letter-spacing: normal;
		text-transform: capitalize;
		color: var(--cas-navy);
	}

	.cas .cas-text {
		margin: 14px 0 0;
		font: 400 16px/21.04px var(--cas-body);
		color: var(--cas-muted);
	}

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

	.cas-point {
		display: flex;
		align-items: flex-start;
		gap: 12px;
		font: 600 18px/26px var(--cas-display);
		color: var(--cas-navy);
	}

	.cas-chev {
		flex: none;
		margin-top: 6px;
		fill: none;
		stroke: var(--cas-orange);
		stroke-width: 2.6;
		stroke-linecap: round;
		stroke-linejoin: round;
	}

	.cas .cas-btn {
		display: inline-flex;
		align-items: center;
		gap: 10px;
		min-height: 48px;
		margin-top: 30px;
		padding: 18px 24px;
		border-radius: 100px;
		background: var(--cas-orange);
		font: 600 14px/12.46px var(--cas-body);
		text-transform: capitalize;
		color: #fff;
		text-decoration: none;
		transition: background-color 0.3s var(--cas-ease);
	}

	.cas-btn-arrow {
		fill: none;
		stroke: currentColor;
		stroke-width: 1.8;
		stroke-linecap: round;
		stroke-linejoin: round;
		transition: transform 0.3s var(--cas-ease);
	}

	.cas .cas-btn:hover {
		background: var(--cas-navy);
	}

	.cas .cas-btn:hover .cas-btn-arrow {
		transform: rotate(45deg);
	}

	.cas .cas-btn:focus-visible {
		outline: 2px solid var(--cas-navy);
		outline-offset: 3px;
	}

	/* ---- appear: media from its outer side, copy from the other ---- */
	.cas-media,
	.cas-copy {
		transition:
			opacity 0.8s var(--cas-ease),
			transform 0.8s var(--cas-ease);
	}

	.cas[data-cas-ready]:not([data-in]) .cas-media,
	.cas[data-cas-ready]:not([data-in]) .cas-copy {
		opacity: 0;
	}

	.cas[data-cas-ready][data-media-side="left"]:not([data-in]) .cas-media,
	.cas[data-cas-ready][data-media-side="right"]:not([data-in]) .cas-copy {
		transform: translateX(-40px);
	}

	.cas[data-cas-ready][data-media-side="left"]:not([data-in]) .cas-copy,
	.cas[data-cas-ready][data-media-side="right"]:not([data-in]) .cas-media {
		transform: translateX(40px);
	}

	/* ---- dialog ---- */
	.cas-dialog {
		width: min(960px, calc(100vw - 32px));
		max-height: calc(100vh - 32px);
		padding: 0;
		border: 0;
		border-radius: 16px;
		background: var(--cas-navy);
		color: #fff;
		overflow: hidden;
	}

	.cas-dialog::backdrop {
		background: rgb(2 14 40 / 0.8);
	}

	.cas-dialog-bar {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 12px;
		padding: 6px 6px 6px 18px;
	}

	.cas .cas-dialog-title {
		margin: 0;
		font: 600 16px/1.3 var(--cas-display);
	}

	.cas-close {
		display: grid;
		place-items: center;
		flex: none;
		width: 44px;
		height: 44px;
		padding: 0;
		border: 0;
		border-radius: 50%;
		background: transparent;
		color: #fff;
		cursor: pointer;
		transition: background-color 0.3s var(--cas-ease);
	}

	.cas-close svg {
		fill: none;
		stroke: currentColor;
		stroke-width: 2;
		stroke-linecap: round;
	}

	.cas-close:hover {
		background: var(--cas-orange);
	}

	.cas-close:focus-visible {
		outline: 2px solid var(--cas-orange);
		outline-offset: 2px;
	}

	.cas-frame {
		position: relative;
		aspect-ratio: 16 / 9;
		background: #000;
	}

	.cas-frame :global(iframe),
	.cas-frame :global(video) {
		position: absolute;
		inset: 0;
		width: 100%;
		height: 100%;
		border: 0;
	}

	/* ---- tablet and up: side by side ---- */
	@media (min-width: 810px) {
		.cas {
			padding: 100px 30px;
		}

		.cas-inner {
			flex-direction: row;
			justify-content: space-between;
			align-items: center;
			gap: 40px;
		}

		.cas-media {
			flex: 0 0 46%;
			aspect-ratio: 576 / 607;
		}

		.cas[data-video] .cas-media {
			flex-basis: 45%;
			aspect-ratio: 564 / 562;
		}

		.cas-copy {
			flex: 0 1 619px;
		}

		.cas .cas-eyebrow {
			margin-bottom: 24px;
			font-size: 18px;
			line-height: 12.6px;
		}

		.cas-rule {
			display: block;
		}

		.cas-heading {
			font-size: 40px;
		}

		.cas .cas-text {
			margin-top: 40px;
		}

		.cas .cas-points {
			gap: 16px;
			margin-top: 40px;
		}

		.cas-point {
			font-size: 20px;
			line-height: 30px;
		}

		.cas-chev {
			margin-top: 8px;
		}

		.cas .cas-btn {
			min-height: 56px;
			margin-top: 50px;
			padding: 22px 30px;
		}
	}

	/* ---- desktop: the measured 1253 row ---- */
	@media (min-width: 1200px) {
		.cas {
			padding: 120px 20px;
		}

		/* Step 39.2: --tg-grid-max / --tg-grid-gutter put the row on a site grid at desktop; the fallbacks give the original 1253px row. */
		.cas-inner {
			--cas-grid-max: var(--tg-grid-max, 1293px);
			--cas-grid-gutter: var(--tg-grid-gutter, 20px);
			max-width: calc(var(--cas-grid-max) - 2 * var(--cas-grid-gutter));
		}

		.cas-heading {
			font-size: 48px;
			line-height: 57.6px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.cas-media,
		.cas-copy,
		.cas-play-disc,
		.cas-btn-arrow {
			transition: none;
		}

		.cas .cas-btn:hover .cas-btn-arrow,
		.cas-play:hover .cas-play-disc {
			transform: none;
		}

		.cas[data-cas-ready]:not([data-in]) .cas-media,
		.cas[data-cas-ready]:not([data-in]) .cas-copy {
			opacity: 1;
			transform: none;
		}
	}

	@media (forced-colors: active) {
		.cas .cas-btn,
		.cas-play-disc {
			border: 1px solid ButtonText;
		}
	}
</style>

<script>
	/*
	 * Appear-on-scroll, plus the video player when the section has one.
	 * One IntersectionObserver per instance; every listener hangs off one
	 * AbortController, aborted on astro:before-swap. The player is a native
	 * modal <dialog>: Escape and the backdrop close it, focus goes back to the
	 * play button, and the iframe or <video> is created on open and removed on
	 * close so nothing keeps playing. Nothing moves focus or scrolls on load.
	 */
	function initCommittedAboutSplit(root: HTMLElement) {
		if (root.dataset.casReady === "") return;
		const controller = new AbortController();
		const { signal } = controller;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

		root.dataset.casReady = "";

		if (reduced || !("IntersectionObserver" in window)) {
			root.dataset.in = "";
		} else {
			const observer = new IntersectionObserver(
				(entries) => {
					if (entries.some((entry) => entry.isIntersecting)) {
						root.dataset.in = "";
						observer.disconnect();
					}
				},
				{ threshold: 0.2 },
			);
			observer.observe(root);
			signal.addEventListener("abort", () => observer.disconnect());
		}

		const play = root.querySelector<HTMLAnchorElement>("[data-cas-play]");
		const dialog = root.querySelector<HTMLDialogElement>("[data-cas-dialog]");
		const frame = dialog?.querySelector<HTMLElement>("[data-cas-frame]");
		const close = dialog?.querySelector<HTMLButtonElement>("[data-cas-close]");

		if (play && dialog && frame && typeof dialog.showModal === "function") {
			const src = dialog.dataset.src ?? "";
			const title = dialog.querySelector(".cas-dialog-title")?.textContent ?? "Video";

			const mount = () => {
				frame.replaceChildren();
				if (dialog.dataset.kind === "file") {
					const video = document.createElement("video");
					video.src = src;
					video.controls = true;
					video.playsInline = true;
					video.autoplay = !reduced;
					video.setAttribute("aria-label", title);
					frame.append(video);
				} else {
					const iframe = document.createElement("iframe");
					const url = new URL(src, location.href);
					if (!reduced) url.searchParams.set("autoplay", "1");
					iframe.src = url.toString();
					iframe.title = title;
					iframe.allow = "autoplay; encrypted-media; picture-in-picture; fullscreen";
					iframe.allowFullscreen = true;
					frame.append(iframe);
				}
			};

			play.addEventListener(
				"click",
				(event) => {
					if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
					event.preventDefault();
					mount();
					dialog.showModal();
					close?.focus();
				},
				{ signal },
			);

			close?.addEventListener("click", () => dialog.close(), { signal });

			/* A click on the backdrop lands on the dialog element itself. */
			dialog.addEventListener(
				"click",
				(event) => {
					if (event.target === dialog) dialog.close();
				},
				{ signal },
			);

			dialog.addEventListener(
				"close",
				() => {
					frame.replaceChildren();
					play.focus();
				},
				{ signal },
			);

			signal.addEventListener("abort", () => {
				if (dialog.open) dialog.close();
				frame.replaceChildren();
			});
		}

		document.addEventListener(
			"astro:before-swap",
			() => {
				controller.abort();
				delete root.dataset.casReady;
			},
			{ once: true, signal },
		);
	}

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

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	document.addEventListener("astro:page-load", boot);
</script>

===== END src/components/CommittedAboutSplit.astro =====

===== FILE (supporting): src/components/CommittedAboutSplit.md =====
# Committed about split

A two-column about section: a rounded photo on one side; on the other an
orange eyebrow with a short rule, a heading, a paragraph, three "»" points
and an orange pill link. One component covers both of the reference's
versions:

- **Home:** photo left, grey `#ecf0f1` band.
- **About:** copy left, and on the right a video thumbnail with a round play
  button that opens the video in a modal player, on white.

Catalog page: `/components/committed-about-split`

## Files required

**`CommittedAboutSplit.astro`** — the whole component and its one script.
No imports, no npm packages.

`CommittedAboutSplit.md` — this file.

`public/committed-about-split/port-dock.webp` and `port-aerial.webp` — the
catalog demo's photo and video thumbnail, passed as `image`. There is no
default photo.

## Basic usage

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

<!-- home -->
<CommittedAboutSplit
  heading="Freight partners who keep your business moving"
  text="Since 1998 Northpass has planned, booked and tracked freight for companies that can't afford a late load."
  points={["Dispatchers who answer the phone", "Inspected equipment, every trip"]}
  cta={{ label: "About More", href: "/about" }}
  image={{ src: "/committed-about-split/port-dock.webp", alt: "Stacked shipping containers on a quay beside a moored cargo ship" }}
/>

<!-- about -->
<CommittedAboutSplit
  mediaSide="right"
  background="#ffffff"
  heading="Freight partners who keep your business moving"
  image={{ src: "/committed-about-split/port-aerial.webp", alt: "" }}
  videoUrl="https://www.youtube-nocookie.com/embed/VIDEO_ID"
  videoTitle="A day at our Oakland cross-dock"
/>
```

Every value above is demo content. Nothing about a business is a default:
with no heading, text, points, button or video the component renders
nothing, and the "About us" eyebrow never renders on its own.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `mediaSide` | `"left" \| "right"?` | `"left"` | Photo side from 810px. Also sets DOM order, so on a phone the photo comes first for `left` and last for `right`, as on the reference. |
| `image` | `{ src?, alt? }?` | none | No `src` renders a labelled placeholder with a neutral hint. A `src` without `alt` is decorative. |
| `videoUrl` | `string?` | — | An embed URL (iframe) or an `.mp4`/`.webm`/`.ogg` file (`<video>`). Turns the photo into a video thumbnail. |
| `videoTitle` | `string?` | `""` | Play button name ("Play video: …"), dialog title, iframe title. Omitted: "Play video" and "Video". |
| `eyebrow` | `string?` | `"About us"` | Generic label. `""` drops it. Shown only when there is other copy. |
| `heading` | `string?` | `""` | Title-cased by CSS, as on the reference. Hidden when empty. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | |
| `text` | `string?` | `""` | Hidden when empty. |
| `points` | `string[]?` | `[]` | Empty drops the list. |
| `cta` | `{ label, href } \| null?` | `null` | `null` drops it. |
| `background` | `string?` | `#ecf0f1` | Section surface. |
| `fontStylesheet` | `string?` | Outfit + DM Sans | `""` skips it. |
| `class`, `id` | `string?` | — | `id` also seeds the dialog ids. |

## Behaviour

- **Appear** (measured): the media slides in from its outer side
  (`translateX(-40px)` when left, `+40px` when right) and the copy from the
  other side, both from `opacity 0`, 0.8s, once, at 20% in view.
- **Pill hover** (measured): bg `#fd5523` → `#020e28`, the ↗ arrow rotates
  45°, 0.3s.
- **Play button** (measured): a 64px white halo at 30% with a 40px white disc
  and an orange triangle, over a 50% navy wash. On hover the disc scales to
  1.05.
- **Video player:** clicking the play button opens a native modal
  `<dialog>`, creates the iframe (with `autoplay=1`, unless reduced motion)
  or `<video>`, and moves focus to the close button. Escape, the close
  button and a click on the backdrop close it; the player element is removed
  so playback stops, and focus returns to the play button. Ctrl/⌘/Shift
  click keeps the link's normal behaviour.
- **Without JS:** the play button is a link that opens the video URL in a
  new tab; everything else is at rest.
- **Reduced motion:** no slides, no hover transforms, no autoplay.
- Lifecycle: one IntersectionObserver and all listeners on one
  AbortController per instance, aborted on `astro:before-swap` (which also
  closes an open dialog). Initialises on load and `astro:page-load`,
  idempotently. Never scrolls or moves focus on load.

## Layout

| Width | Layout |
| :--- | :--- |
| ≥ 1200 | Photo 46% (576×607 in a 1253 row, radius 30) or video 45% (564×562, radius 20); copy up to 619px; 48/57.6 heading; 120px padding. |
| 810–1199 | Same split, 40px heading, 100px padding. |
| < 810 | Stacked in DOM order, 30px gap; photo 350:278, video square; 32px heading; eyebrow rule hidden; 80px padding. |

## Accessibility

- The "»" icons are decorative SVG; the points are a real list.
- The play button is an `<a>` with `aria-haspopup="dialog"` and a name that
  says what it plays. The dialog is labelled by its visible title.
- Focus rings on the pill (navy), play button (white) and close button
  (orange). Close button is 44px.

## Images and credits

| File | Size | Unsplash id |
| :--- | :--- | :--- |
| `public/committed-about-split/port-dock.webp` | 1152×1214 | MHXJ9p64Jw8 |
| `public/committed-about-split/port-aerial.webp` | 1128×1124 | IVG8SDczupk |

Unsplash License (free commercial use, no attribution required).

The demo `videoUrl` in the catalog is Blender's *Big Buck Bunny* on YouTube
(`aqz-KE-bpKQ`, checked via YouTube's oEmbed) — a stand-in only.

## Approximations

- The reference's video player wasn't captured (only the thumbnail and the
  1.05 hover), so the dialog's look — navy frame, title bar, 16:9 — is this
  library's design.
- Phone sizes (32px heading, 350:278 photo) are read off the 390 screenshot;
  tablet was not captured.
- The reference's radius differs between the two variants (30px photo, 20px
  video); that is kept, keyed off whether `videoUrl` is set.

===== END src/components/CommittedAboutSplit.md =====

===== FILE (required): public/committed-about-split/port-dock.webp =====
[binary asset — download https://astro.baysixmedia.com/committed-about-split/port-dock.webp and save it to public/committed-about-split/port-dock.webp]

===== FILE (required): public/committed-about-split/port-aerial.webp =====
[binary asset — download https://astro.baysixmedia.com/committed-about-split/port-aerial.webp and save it to public/committed-about-split/port-aerial.webp]

--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.

--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.

--- 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
“Committed about split” from my Astro Component Library:
https://astro.baysixmedia.com/components/committed-about-split/

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
core only
Installation files
3 required + 1 supporting
Verified
Never
Client navigation
Event-tested only — not certified against a real router
Source fingerprint
No fingerprint recorded
Production ready
Not yet

Not yet verified

Enrolled for verification, but never run. Silence is reported as silence rather than assumed to be fine.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/CommittedAboutSplit.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/committed-about-split/port-dock.webp, public/committed-about-split/port-aerial.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * CommittedAboutSplit
 *
 * A two-column about section: a rounded photo on one side, and on the other an
 * orange eyebrow with a short rule, a heading, a paragraph, three "»" points
 * and an orange pill link. `mediaSide` puts the photo left or right.
 *
 * Give it a `videoUrl` and the photo becomes a video thumbnail: a navy wash,
 * a round play button in the middle, and a modal <dialog> that plays the
 * video (a YouTube/Vimeo embed in an iframe, or an .mp4/.webm file in a
 * <video>). Without JavaScript the play button is a plain link to the video.
 *
 * On scroll the photo slides in from its outer side (40px) and the copy from
 * the other side.
 *
 * Reads --tg-* design tokens from any ancestor, with fallbacks.
 */

export interface CommittedAboutSplitLink {
	label: string;
	href: string;
}

export interface CommittedAboutSplitProps {
	/** Which side the photo sits on at 810px and up. On a phone the DOM order holds. */
	mediaSide?: "left" | "right";
	/** The photo, or the video thumbnail. No `src` renders the labelled placeholder. */
	image?: { src?: string; alt?: string };
	/** Turns the photo into a video thumbnail with a play button and a modal player. */
	videoUrl?: string;
	/** Accessible name for the play button and the player. Omitted: "Play video" / "Video". */
	videoTitle?: string;
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	text?: string;
	/** The "»" points. `[]` drops the list. */
	points?: string[];
	/** The orange pill. `null` (the default) drops it. */
	cta?: CommittedAboutSplitLink | null;
	/** Section surface. The home variant is #ecf0f1, the about variant white. */
	background?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends CommittedAboutSplitProps {}

/*
 * The heading, copy, points, button, photo and video title are the caller's:
 * nothing about a business is filled in here. "About us" is a generic label
 * and never renders the section on its own. The catalog demo passes its
 * sample content through the registry's `props`.
 */
const {
	mediaSide = "left",
	image: imageProp,
	videoUrl,
	videoTitle = "",
	eyebrow = "About us",
	heading = "",
	headingLevel = "h2",
	text = "",
	points = [],
	cta = null,
	background = "#ecf0f1",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=DM+Sans:wght@400;500;600&display=swap",
	class: className,
	id,
} = Astro.props;

const hasVideo = Boolean(videoUrl && videoUrl.trim());
/* A photo given without alt is decorative. No photo: the neutral placeholder. */
const image = { src: imageProp?.src ?? "", alt: imageProp?.alt ?? "" };
const pointList = (points ?? []).filter((p) => typeof p === "string" && p.trim());
const title = videoTitle.trim();
const playLabel = title ? `Play video: ${title}` : "Play video";

/* Copy worth a column; the generic eyebrow alone is not. */
const hasCopy = Boolean(heading || text || pointList.length > 0 || cta);
/* Nothing to say → nothing rendered. */
const hasContent = hasCopy || hasVideo;

const Heading = headingLevel;
const uid = id ?? `cas-${Math.random().toString(36).slice(2, 8)}`;
const dialogId = `${uid}-player`;
const titleId = `${uid}-player-title`;

const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
const video = hasVideo ? safeHref(videoUrl!) : "";
const isFile = /\.(mp4|webm|ogg)(\?|#|$)/i.test(video);

const IMAGE_SPEC = hasVideo ? "1:1 · 1128 × 1124 px" : "~0.95:1 · 1152 × 1214 px";
const IMAGE_HINT = hasVideo ? "Video thumbnail, square" : "Photo, near-square portrait";
---

{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<section
	class:list={["cas", className]}
	id={id}
	style={`--cas-bg: ${background};`}
	data-cas-root
	data-media-side={mediaSide}
	data-video={hasVideo ? "" : undefined}
>
	<div class="cas-inner">
		{
			mediaSide === "right" && hasCopy && (
				<div class="cas-copy" data-cas-copy>
					{eyebrow && (
						<p class="cas-eyebrow">
							{eyebrow}
							<span class="cas-rule" aria-hidden="true" />
						</p>
					)}
					{heading && <Heading class="cas-heading">{heading}</Heading>}
					{text && <p class="cas-text">{text}</p>}
					{pointList.length > 0 && (
						<ul class="cas-points" role="list">
							{pointList.map((point) => (
								<li class="cas-point">
									<svg class="cas-chev" viewBox="0 0 18 14" width="18" height="14" aria-hidden="true" focusable="false">
										<path d="M2 1.5 7.5 7 2 12.5M9.5 1.5 15 7l-5.5 5.5" />
									</svg>
									<span>{point}</span>
								</li>
							))}
						</ul>
					)}
					{cta && (
						<a class="cas-btn" href={safeHref(cta.href)}>
							{cta.label}
							<svg class="cas-btn-arrow" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
								<path d="M2 10 10 2M3.5 2H10v6.5" />
							</svg>
						</a>
					)}
				</div>
			)
		}

		<div class="cas-media" data-cas-media>
			{
				image.src ? (
					<img
						src={image.src}
						alt={image.alt}
						width={hasVideo ? 1128 : 1152}
						height={hasVideo ? 1124 : 1214}
						loading="lazy"
						decoding="async"
					/>
				) : (
					<div class="cas-ph" role="img" aria-label={`Image placeholder: ${IMAGE_HINT}`}>
						<div class="cas-ph-text" aria-hidden="true">
							<span class="cas-ph-label">Image</span>
							<span class="cas-ph-hint">{IMAGE_HINT}</span>
							<span class="cas-ph-spec">{IMAGE_SPEC}</span>
						</div>
					</div>
				)
			}
			{
				hasVideo && (
					<>
						<span class="cas-wash" aria-hidden="true" />
						<a
							class="cas-play"
							href={video}
							target="_blank"
							rel="noopener"
							aria-label={playLabel}
							aria-haspopup="dialog"
							data-cas-play
						>
							<span class="cas-play-halo" aria-hidden="true" />
							<span class="cas-play-disc" aria-hidden="true">
								<svg viewBox="0 0 12 14" width="12" height="14" focusable="false">
									<path d="M1 1.2v11.6a.6.6 0 0 0 .9.5l9.6-5.8a.6.6 0 0 0 0-1L1.9.7a.6.6 0 0 0-.9.5Z" />
								</svg>
							</span>
						</a>
					</>
				)
			}
		</div>

		{
			mediaSide !== "right" && hasCopy && (
				<div class="cas-copy" data-cas-copy>
					{eyebrow && (
						<p class="cas-eyebrow">
							{eyebrow}
							<span class="cas-rule" aria-hidden="true" />
						</p>
					)}
					{heading && <Heading class="cas-heading">{heading}</Heading>}
					{text && <p class="cas-text">{text}</p>}
					{pointList.length > 0 && (
						<ul class="cas-points" role="list">
							{pointList.map((point) => (
								<li class="cas-point">
									<svg class="cas-chev" viewBox="0 0 18 14" width="18" height="14" aria-hidden="true" focusable="false">
										<path d="M2 1.5 7.5 7 2 12.5M9.5 1.5 15 7l-5.5 5.5" />
									</svg>
									<span>{point}</span>
								</li>
							))}
						</ul>
					)}
					{cta && (
						<a class="cas-btn" href={safeHref(cta.href)}>
							{cta.label}
							<svg class="cas-btn-arrow" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
								<path d="M2 10 10 2M3.5 2H10v6.5" />
							</svg>
						</a>
					)}
				</div>
			)
		}
	</div>

	{
		hasVideo && (
			<dialog class="cas-dialog" id={dialogId} aria-labelledby={titleId} data-cas-dialog data-src={video} data-kind={isFile ? "file" : "embed"}>
				<div class="cas-dialog-bar">
					<p class="cas-dialog-title" id={titleId}>{title || "Video"}</p>
					<button type="button" class="cas-close" data-cas-close aria-label="Close video">
						<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false">
							<path d="M6 6l12 12M18 6 6 18" />
						</svg>
					</button>
				</div>
				<div class="cas-frame" data-cas-frame />
			</dialog>
		)
	}
</section>
)}

<style>
	.cas {
		--cas-navy: var(--tg-navy, #020e28);
		--cas-orange: var(--tg-orange, #fd5523);
		--cas-muted: var(--tg-muted, #61697d);
		--cas-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
		--cas-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
		--cas-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
		--cas-radius: 30px;

		box-sizing: border-box;
		padding: 80px 20px;
		background: var(--cas-bg, #ecf0f1);
		color: var(--cas-navy);
		overflow-x: clip;
	}

	.cas[data-video] {
		--cas-radius: 20px;
	}

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

	.cas-inner {
		display: flex;
		flex-direction: column;
		gap: 30px;
		max-width: 1253px;
		margin: 0 auto;
	}

	/* ---- media ---- */
	.cas-media {
		position: relative;
		width: 100%;
		aspect-ratio: 350 / 278;
		border-radius: var(--cas-radius);
		overflow: hidden;
		background: #dfe5e7;
	}

	.cas[data-video] .cas-media {
		aspect-ratio: 1;
	}

	.cas-media img {
		position: absolute;
		inset: 0;
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	.cas-wash {
		position: absolute;
		inset: 0;
		background: rgb(2 14 40 / 0.5);
	}

	.cas-play {
		position: absolute;
		top: 50%;
		left: 50%;
		display: grid;
		place-items: center;
		width: 64px;
		height: 64px;
		margin: -32px 0 0 -32px;
		border-radius: 50%;
		cursor: pointer;
	}

	.cas-play-halo {
		position: absolute;
		inset: 0;
		border-radius: 50%;
		background: #fff;
		opacity: 0.3;
	}

	.cas-play-disc {
		position: relative;
		display: grid;
		place-items: center;
		width: 40px;
		height: 40px;
		padding-left: 2px;
		border-radius: 50%;
		background: #fff;
		transition: transform 0.3s var(--cas-ease);
	}

	.cas-play-disc svg {
		fill: var(--cas-orange);
	}

	.cas-play:hover .cas-play-disc {
		transform: scale(1.05);
	}

	.cas-play:focus-visible {
		outline: 3px solid #fff;
		outline-offset: 4px;
	}

	.cas-ph {
		display: grid;
		place-items: center;
		width: 100%;
		height: 100%;
		padding: 16px;
		text-align: center;
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		color: rgb(2 14 40 / 0.62);
	}

	.cas-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 8px;
	}

	.cas-ph-label {
		font: 600 12px/1 var(--cas-body);
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.cas-ph-hint {
		max-width: 28ch;
		font: 500 14px/1.4 var(--cas-body);
	}

	.cas-ph-spec {
		font: 400 12px/1.2 var(--cas-body);
	}

	/* ---- copy ---- */
	.cas-copy {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		min-width: 0;
	}

	.cas .cas-eyebrow {
		display: inline-flex;
		align-items: center;
		gap: 12px;
		margin: 0 0 16px;
		font: 600 16px/1 var(--cas-display);
		text-transform: uppercase;
		color: var(--cas-orange);
	}

	.cas-rule {
		display: none;
		width: 40px;
		height: 1px;
		background: var(--cas-orange);
	}

	.cas-heading {
		margin: 0;
		font: 600 32px/1.2 var(--cas-display);
		letter-spacing: normal;
		text-transform: capitalize;
		color: var(--cas-navy);
	}

	.cas .cas-text {
		margin: 14px 0 0;
		font: 400 16px/21.04px var(--cas-body);
		color: var(--cas-muted);
	}

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

	.cas-point {
		display: flex;
		align-items: flex-start;
		gap: 12px;
		font: 600 18px/26px var(--cas-display);
		color: var(--cas-navy);
	}

	.cas-chev {
		flex: none;
		margin-top: 6px;
		fill: none;
		stroke: var(--cas-orange);
		stroke-width: 2.6;
		stroke-linecap: round;
		stroke-linejoin: round;
	}

	.cas .cas-btn {
		display: inline-flex;
		align-items: center;
		gap: 10px;
		min-height: 48px;
		margin-top: 30px;
		padding: 18px 24px;
		border-radius: 100px;
		background: var(--cas-orange);
		font: 600 14px/12.46px var(--cas-body);
		text-transform: capitalize;
		color: #fff;
		text-decoration: none;
		transition: background-color 0.3s var(--cas-ease);
	}

	.cas-btn-arrow {
		fill: none;
		stroke: currentColor;
		stroke-width: 1.8;
		stroke-linecap: round;
		stroke-linejoin: round;
		transition: transform 0.3s var(--cas-ease);
	}

	.cas .cas-btn:hover {
		background: var(--cas-navy);
	}

	.cas .cas-btn:hover .cas-btn-arrow {
		transform: rotate(45deg);
	}

	.cas .cas-btn:focus-visible {
		outline: 2px solid var(--cas-navy);
		outline-offset: 3px;
	}

	/* ---- appear: media from its outer side, copy from the other ---- */
	.cas-media,
	.cas-copy {
		transition:
			opacity 0.8s var(--cas-ease),
			transform 0.8s var(--cas-ease);
	}

	.cas[data-cas-ready]:not([data-in]) .cas-media,
	.cas[data-cas-ready]:not([data-in]) .cas-copy {
		opacity: 0;
	}

	.cas[data-cas-ready][data-media-side="left"]:not([data-in]) .cas-media,
	.cas[data-cas-ready][data-media-side="right"]:not([data-in]) .cas-copy {
		transform: translateX(-40px);
	}

	.cas[data-cas-ready][data-media-side="left"]:not([data-in]) .cas-copy,
	.cas[data-cas-ready][data-media-side="right"]:not([data-in]) .cas-media {
		transform: translateX(40px);
	}

	/* ---- dialog ---- */
	.cas-dialog {
		width: min(960px, calc(100vw - 32px));
		max-height: calc(100vh - 32px);
		padding: 0;
		border: 0;
		border-radius: 16px;
		background: var(--cas-navy);
		color: #fff;
		overflow: hidden;
	}

	.cas-dialog::backdrop {
		background: rgb(2 14 40 / 0.8);
	}

	.cas-dialog-bar {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 12px;
		padding: 6px 6px 6px 18px;
	}

	.cas .cas-dialog-title {
		margin: 0;
		font: 600 16px/1.3 var(--cas-display);
	}

	.cas-close {
		display: grid;
		place-items: center;
		flex: none;
		width: 44px;
		height: 44px;
		padding: 0;
		border: 0;
		border-radius: 50%;
		background: transparent;
		color: #fff;
		cursor: pointer;
		transition: background-color 0.3s var(--cas-ease);
	}

	.cas-close svg {
		fill: none;
		stroke: currentColor;
		stroke-width: 2;
		stroke-linecap: round;
	}

	.cas-close:hover {
		background: var(--cas-orange);
	}

	.cas-close:focus-visible {
		outline: 2px solid var(--cas-orange);
		outline-offset: 2px;
	}

	.cas-frame {
		position: relative;
		aspect-ratio: 16 / 9;
		background: #000;
	}

	.cas-frame :global(iframe),
	.cas-frame :global(video) {
		position: absolute;
		inset: 0;
		width: 100%;
		height: 100%;
		border: 0;
	}

	/* ---- tablet and up: side by side ---- */
	@media (min-width: 810px) {
		.cas {
			padding: 100px 30px;
		}

		.cas-inner {
			flex-direction: row;
			justify-content: space-between;
			align-items: center;
			gap: 40px;
		}

		.cas-media {
			flex: 0 0 46%;
			aspect-ratio: 576 / 607;
		}

		.cas[data-video] .cas-media {
			flex-basis: 45%;
			aspect-ratio: 564 / 562;
		}

		.cas-copy {
			flex: 0 1 619px;
		}

		.cas .cas-eyebrow {
			margin-bottom: 24px;
			font-size: 18px;
			line-height: 12.6px;
		}

		.cas-rule {
			display: block;
		}

		.cas-heading {
			font-size: 40px;
		}

		.cas .cas-text {
			margin-top: 40px;
		}

		.cas .cas-points {
			gap: 16px;
			margin-top: 40px;
		}

		.cas-point {
			font-size: 20px;
			line-height: 30px;
		}

		.cas-chev {
			margin-top: 8px;
		}

		.cas .cas-btn {
			min-height: 56px;
			margin-top: 50px;
			padding: 22px 30px;
		}
	}

	/* ---- desktop: the measured 1253 row ---- */
	@media (min-width: 1200px) {
		.cas {
			padding: 120px 20px;
		}

		/* Step 39.2: --tg-grid-max / --tg-grid-gutter put the row on a site grid at desktop; the fallbacks give the original 1253px row. */
		.cas-inner {
			--cas-grid-max: var(--tg-grid-max, 1293px);
			--cas-grid-gutter: var(--tg-grid-gutter, 20px);
			max-width: calc(var(--cas-grid-max) - 2 * var(--cas-grid-gutter));
		}

		.cas-heading {
			font-size: 48px;
			line-height: 57.6px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.cas-media,
		.cas-copy,
		.cas-play-disc,
		.cas-btn-arrow {
			transition: none;
		}

		.cas .cas-btn:hover .cas-btn-arrow,
		.cas-play:hover .cas-play-disc {
			transform: none;
		}

		.cas[data-cas-ready]:not([data-in]) .cas-media,
		.cas[data-cas-ready]:not([data-in]) .cas-copy {
			opacity: 1;
			transform: none;
		}
	}

	@media (forced-colors: active) {
		.cas .cas-btn,
		.cas-play-disc {
			border: 1px solid ButtonText;
		}
	}
</style>

<script>
	/*
	 * Appear-on-scroll, plus the video player when the section has one.
	 * One IntersectionObserver per instance; every listener hangs off one
	 * AbortController, aborted on astro:before-swap. The player is a native
	 * modal <dialog>: Escape and the backdrop close it, focus goes back to the
	 * play button, and the iframe or <video> is created on open and removed on
	 * close so nothing keeps playing. Nothing moves focus or scrolls on load.
	 */
	function initCommittedAboutSplit(root: HTMLElement) {
		if (root.dataset.casReady === "") return;
		const controller = new AbortController();
		const { signal } = controller;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

		root.dataset.casReady = "";

		if (reduced || !("IntersectionObserver" in window)) {
			root.dataset.in = "";
		} else {
			const observer = new IntersectionObserver(
				(entries) => {
					if (entries.some((entry) => entry.isIntersecting)) {
						root.dataset.in = "";
						observer.disconnect();
					}
				},
				{ threshold: 0.2 },
			);
			observer.observe(root);
			signal.addEventListener("abort", () => observer.disconnect());
		}

		const play = root.querySelector<HTMLAnchorElement>("[data-cas-play]");
		const dialog = root.querySelector<HTMLDialogElement>("[data-cas-dialog]");
		const frame = dialog?.querySelector<HTMLElement>("[data-cas-frame]");
		const close = dialog?.querySelector<HTMLButtonElement>("[data-cas-close]");

		if (play && dialog && frame && typeof dialog.showModal === "function") {
			const src = dialog.dataset.src ?? "";
			const title = dialog.querySelector(".cas-dialog-title")?.textContent ?? "Video";

			const mount = () => {
				frame.replaceChildren();
				if (dialog.dataset.kind === "file") {
					const video = document.createElement("video");
					video.src = src;
					video.controls = true;
					video.playsInline = true;
					video.autoplay = !reduced;
					video.setAttribute("aria-label", title);
					frame.append(video);
				} else {
					const iframe = document.createElement("iframe");
					const url = new URL(src, location.href);
					if (!reduced) url.searchParams.set("autoplay", "1");
					iframe.src = url.toString();
					iframe.title = title;
					iframe.allow = "autoplay; encrypted-media; picture-in-picture; fullscreen";
					iframe.allowFullscreen = true;
					frame.append(iframe);
				}
			};

			play.addEventListener(
				"click",
				(event) => {
					if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
					event.preventDefault();
					mount();
					dialog.showModal();
					close?.focus();
				},
				{ signal },
			);

			close?.addEventListener("click", () => dialog.close(), { signal });

			/* A click on the backdrop lands on the dialog element itself. */
			dialog.addEventListener(
				"click",
				(event) => {
					if (event.target === dialog) dialog.close();
				},
				{ signal },
			);

			dialog.addEventListener(
				"close",
				() => {
					frame.replaceChildren();
					play.focus();
				},
				{ signal },
			);

			signal.addEventListener("abort", () => {
				if (dialog.open) dialog.close();
				frame.replaceChildren();
			});
		}

		document.addEventListener(
			"astro:before-swap",
			() => {
				controller.abort();
				delete root.dataset.casReady;
			},
			{ once: true, signal },
		);
	}

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

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	document.addEventListener("astro:page-load", boot);
</script>