Component library
Back to catalog

Control tower showcase

A dark product section: lime-dot eyebrow, huge white heading, paragraph, four check-mark features and a lime pill link, beside a light dashboard mock with a status pill, a photo tile and three KPI tiles. Features and tiles slide in on scroll.

platformdashboardfeaturesdarkappearhalcyon

Open preview in a new tab

Use this component

Three ways to get Control tower showcase 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 2 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 Control tower showcase from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/control-tower-showcase/

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 2 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: "Control tower showcase"
Library page: https://astro.baysixmedia.com/components/control-tower-showcase/
Files: 2 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/ControlTowerShowcase.astro =====
---
/**
 * ControlTowerShowcase
 *
 * A dark product section. Left: lime-dot eyebrow, a huge white heading, a
 * paragraph, a list of features with lime check discs, and a lime pill link.
 * Right: a light, rounded dashboard mock — a header with a timestamp and a
 * status pill, a photo tile, and a stack of three white KPI tiles (a small
 * bar chart, a figure with a week-on-week change, and a plain figure).
 *
 * Appear on scroll: the feature rows slide in from the left and the KPI tiles
 * rise and scale in, both staggered, once. The hidden starting state is only
 * applied after the script has marked the root ready, so with no script or
 * under reduced motion everything renders at rest.
 *
 * Two columns from 1200px, stacked below. Reads --nv-* tokens, with fallbacks.
 */

export interface ControlTowerShowcaseProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	body?: string;
	features?: string[];
	cta?: { label: string; href: string } | null;
	dashboard?: {
		title?: string;
		/** Static text. Not a live clock. */
		timestamp?: string;
		/** Status pill text. `""` drops the pill. */
		status?: string;
		/** Photo tile. `src: ""` renders the labelled placeholder. `null` drops the tile. */
		image?: { src?: string; alt?: string } | null;
		onTime?: { label: string; value: string; bars?: number[]; highlight?: number[] };
		exceptions?: { label: string; value: string; delta?: string };
		carbon?: { label: string; value: string };
	};
	/** The scroll-in motion. `false` renders everything at rest. */
	animate?: boolean;
	background?: string;
	fontStylesheet?: string;
	id?: string;
}

interface Props extends ControlTowerShowcaseProps {}

/*
 * No demo dashboard and nothing merged into one: the title, timestamp,
 * status, photo and every figure are drawn only when the caller passes them.
 * A KPI needs both a label and a value; a partial one is not drawn — never an
 * invented 98.7% beside real figures, and never one on a page that passed
 * none. (The catalog demo passes its own dashboard through the registry.)
 */
const {
	eyebrow = "",
	heading = "",
	headingLevel = "h2",
	body = "",
	features = [],
	cta = null,
	dashboard = {},
	animate = true,
	background = "#08110f",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700;800&display=swap",
	id,
} = Astro.props;

const filled = (v: unknown): v is string => typeof v === "string" && v.trim() !== "";
const stat = <T extends { label?: string; value?: string }>(s: T | undefined | null) =>
	s && filled(s.label) && filled(s.value) ? s : undefined;

const raw = dashboard ?? {};
const d = {
	title: raw.title,
	timestamp: raw.timestamp,
	status: raw.status,
	onTime: stat(raw.onTime),
	exceptions: stat(raw.exceptions),
	carbon: stat(raw.carbon),
};
const image = raw.image ? { src: raw.image.src ?? "", alt: raw.image.alt ?? "" } : null;
const hasKpis = Boolean(d.onTime || d.exceptions || d.carbon);
/* The dashboard needs something in its body — a photo or a figure. A title,
   timestamp or status pill alone does not draw an empty panel. */
const showDash = Boolean(image) || hasKpis;
const bars = (d.onTime?.bars ?? []).filter((n) => Number.isFinite(n) && n > 0);
const barMax = Math.max(1, ...bars);
const highlight = new Set(d.onTime?.highlight ?? []);
const Heading = headingLevel;
const featureList = Array.isArray(features) ? features.filter(filled) : [];
const link = cta && filled(cta.label) && filled(cta.href) ? cta : null;
/* The eyebrow alone never renders the section. */
const hasContent = filled(heading) || filled(body) || featureList.length > 0 || Boolean(link) || showDash;
---

{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<section class="cts" id={id} style={`--cts-bg: ${background};`} data-cts-root data-cts-animate={animate ? "1" : "0"}>
	<div class="cts-grid">
		<div class="cts-content">
			{
				eyebrow && (
					<p class="cts-eyebrow">
						<span class="cts-dot" aria-hidden="true" />
						{eyebrow}
					</p>
				)
			}
			{heading && <Heading class="cts-heading">{heading}</Heading>}
			{body && <p class="cts-body">{body}</p>}
			{
				featureList.length > 0 && (
					<ul class="cts-features" role="list" data-cts-group>
						{featureList.map((f, i) => (
							<li class="cts-feature" style={`--cts-i:${i}`}>
								<span class="cts-check" aria-hidden="true">
									<svg viewBox="0 0 16 16" focusable="false">
										<path d="M3.5 8.4l2.9 2.9 6-6.3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
									</svg>
								</span>
								<span>{f}</span>
							</li>
						))}
					</ul>
				)
			}
			{
				link && (
					<a class="cts-cta" href={link.href}>
						<span>{link.label}</span>
						<svg viewBox="0 0 18 18" aria-hidden="true" focusable="false">
							<path d="M3.5 9h11M10 4.5L14.5 9 10 13.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
						</svg>
					</a>
				)
			}
		</div>

		{showDash && (
		<div class="cts-visual">
			<div class="cts-dash" role="group" aria-label={d.title ? `${d.title} dashboard (illustration)` : "Dashboard illustration"}>
				<div class="cts-dash-head">
					<div class="cts-dash-id">
						{d.title && <p class="cts-dash-title">{d.title}</p>}
						{d.timestamp && <p class="cts-dash-time">{d.timestamp}</p>}
					</div>
					{
						d.status && (
							<p class="cts-status">
								<span class="cts-dot cts-dot--sm" aria-hidden="true" />
								{d.status}
							</p>
						)
					}
				</div>
				<div class:list={["cts-dash-main", { "cts-dash-main--noimg": !image }]}>
					{
						image && (
							<div class="cts-photo">
								{image.src ? (
									<img src={image.src} alt={image.alt ?? ""} width="900" height="1080" loading="lazy" decoding="async" />
								) : (
									<div class="cts-ph" role="img" aria-label={image.alt ? `Image placeholder: ${image.alt}` : "Image placeholder"}>
										<span class="cts-ph-text" aria-hidden="true">
											<span class="cts-ph-label">Image</span>
											{image.alt && <span>{image.alt}</span>}
											<span class="cts-ph-spec">5:6 · 900 × 1080 px</span>
										</span>
									</div>
								)}
							</div>
						)
					}
					{hasKpis && (
						<ul class="cts-kpis" role="list" data-cts-group>
							{
								d.onTime && (
									<li class="cts-kpi" style="--cts-i:0">
										<p class="cts-kpi-label">{d.onTime.label}</p>
										<p class="cts-kpi-value">{d.onTime.value}</p>
										{bars.length > 0 && (
											<div class="cts-bars" aria-hidden="true">
												{bars.map((b, i) => (
													<span
														class:list={["cts-bar", { "cts-bar--on": highlight.has(i) }]}
														style={`height:${Math.round((b / barMax) * 100)}%`}
													/>
												))}
											</div>
										)}
									</li>
								)
							}
							{
								d.exceptions && (
									<li class="cts-kpi" style="--cts-i:1">
										<p class="cts-kpi-label">{d.exceptions.label}</p>
										<p class="cts-kpi-value">{d.exceptions.value}</p>
										{d.exceptions.delta && <p class="cts-kpi-delta">{d.exceptions.delta}</p>}
									</li>
								)
							}
							{
								d.carbon && (
									<li class="cts-kpi" style="--cts-i:2">
										<p class="cts-kpi-label">{d.carbon.label}</p>
										<p class="cts-kpi-value">{d.carbon.value}</p>
									</li>
								)
							}
						</ul>
					)}
				</div>
			</div>
		</div>
		)}
	</div>
</section>
)}

<style>
	.cts {
		--cts-ink: var(--nv-ink, #08110f);
		--cts-lime: var(--nv-lime, #b7ff39);
		--cts-font: var(--nv-font-ui, Inter, ui-sans-serif, system-ui, sans-serif);
		--cts-ease: cubic-bezier(0.22, 1, 0.36, 1);

		box-sizing: border-box;
		padding: 82px 18px;
		background: var(--cts-bg);
		color: #fff;
		font-family: var(--cts-font);
		overflow: clip;
	}

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

	.cts p {
		margin: 0;
	}

	.cts-grid {
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		gap: 52px;
		max-width: 1240px;
		margin: 0 auto;
	}

	.cts-content {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 24px;
		min-width: 0;
	}

	.cts-eyebrow {
		display: flex;
		align-items: center;
		gap: 9px;
		font: 700 12px/14.4px var(--cts-font);
		letter-spacing: 1.56px;
		text-transform: uppercase;
		color: #fff;
	}

	.cts-dot {
		flex: none;
		width: 7px;
		height: 7px;
		border-radius: 100px;
		background: var(--cts-lime);
	}

	.cts-heading {
		max-width: 6.4em;
		margin: 0;
		font-weight: 700;
		font-size: clamp(42px, 13.4vw, 52px);
		line-height: 0.98;
		letter-spacing: -0.05em;
		color: #fff;
		text-wrap: balance;
	}

	.cts-body {
		max-width: 550px;
		font: 400 17px/28.9px var(--cts-font);
		color: rgb(255 255 255 / 0.65);
	}

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

	.cts-feature {
		display: flex;
		align-items: center;
		gap: 13px;
		font: 600 14px/16.8px var(--cts-font);
		color: #fff;
	}

	.cts-check {
		display: grid;
		place-items: center;
		flex: none;
		width: 28px;
		height: 28px;
		border-radius: 100px;
		background: var(--cts-lime);
		color: var(--cts-ink);
	}

	.cts-check svg {
		width: 15px;
		height: 15px;
	}

	.cts-cta {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		gap: 10px;
		height: 52px;
		padding: 0 21px;
		border-radius: 100px;
		background: var(--cts-lime);
		color: var(--cts-ink);
		font: 700 13px/15.6px var(--cts-font);
		text-decoration: none;
		transition: transform 0.3s var(--cts-ease);
	}

	.cts-cta svg {
		width: 17px;
		height: 17px;
	}

	.cts-cta:hover {
		transform: translateY(-2px) scale(1.1);
	}

	.cts-cta:focus-visible {
		outline: 2px solid #fff;
		outline-offset: 3px;
	}

	/* ---- dashboard mock ---- */

	.cts-visual {
		min-width: 0;
	}

	.cts-dash {
		display: flex;
		flex-direction: column;
		gap: 14px;
		padding: 20px;
		border-radius: 32px;
		background: #f4f6f2;
		color: var(--cts-ink);
		box-shadow: 0 24px 60px 0 rgb(0 0 0 / 0.32);
		overflow: clip;
	}

	.cts-dash-head {
		display: flex;
		align-items: center;
		justify-content: space-between;
		flex-wrap: wrap;
		gap: 10px;
		padding: 8px 5px 19px;
	}

	.cts-dash-id {
		display: flex;
		flex-direction: column;
		gap: 3px;
	}

	.cts-dash-title {
		font: 400 12px/14.4px var(--cts-font);
	}

	.cts-dash-time {
		font: 700 16px/19.2px var(--cts-font);
	}

	.cts-status {
		display: inline-flex;
		align-items: center;
		gap: 7px;
		height: 30px;
		padding: 0 11px;
		border-radius: 100px;
		background: rgb(177 255 57 / 0.28);
		font: 700 10px/12px var(--cts-font);
		letter-spacing: 0.5px;
		text-transform: uppercase;
		white-space: nowrap;
	}

	.cts-dash-main {
		display: flex;
		flex-direction: column;
		gap: 14px;
	}

	.cts-photo {
		position: relative;
		height: 250px;
		border-radius: 22px;
		overflow: hidden;
		background: #0d1614;
	}

	.cts-photo img {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	.cts-ph {
		display: grid;
		place-items: center;
		width: 100%;
		height: 100%;
		padding: 16px;
		text-align: center;
		background: repeating-linear-gradient(135deg, #e3e7e1 0 12px, #ebeee9 12px 24px);
		color: rgb(8 17 15 / 0.62);
	}

	.cts-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 6px;
		font: 500 12px/1.35 var(--cts-font);
	}

	.cts-ph-label {
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.cts-ph-spec {
		font-weight: 400;
	}

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

	.cts-kpi {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 8px;
		padding: 18px;
		border-radius: 20px;
		background: #fff;
	}

	.cts-kpi-label {
		font: 500 12px/14.4px var(--cts-font);
		color: rgb(8 17 15 / 0.58);
	}

	.cts-kpi-value {
		font: 700 28px/33.6px var(--cts-font);
		color: var(--cts-ink);
	}

	.cts-kpi-delta {
		margin-top: -1px;
		font: 600 12px/14.4px var(--cts-font);
		color: #288047;
	}

	.cts-bars {
		display: flex;
		align-items: flex-end;
		gap: 7px;
		width: 100%;
		height: 58px;
		margin-top: 4px;
	}

	.cts-bar {
		flex: 1 1 0;
		min-width: 0;
		border-radius: 5px 5px 0 0;
		background: rgb(125 145 133 / 0.28);
	}

	.cts-bar--on {
		background: var(--cts-lime);
	}

	/* ---- tablet: still stacked, dashboard side by side inside ---- */
	@media (min-width: 810px) {
		.cts {
			padding: 100px 40px;
		}

		.cts-heading {
			font-size: 62px;
		}

		.cts-visual {
			max-width: 640px;
		}

		.cts-dash-main {
			flex-direction: row;
			align-items: stretch;
		}

		.cts-photo {
			flex: 1 1 auto;
			height: auto;
			min-height: 420px;
		}

		.cts-kpis {
			flex: 0 0 220px;
		}

		/* Tiles share out the photo's height, as the reference's stack does. */
		.cts-kpi {
			flex: 1 1 auto;
		}

		.cts-dash-main--noimg .cts-kpis {
			flex: 1 1 auto;
		}
	}

	/* ---- desktop: two columns ---- */
	@media (min-width: 1200px) {
		.cts {
			padding: 120px 48px;
		}

		.cts-grid {
			grid-template-columns: repeat(2, minmax(0, 584px));
			justify-content: center;
			align-items: start;
			gap: 72px;
		}

		.cts-heading {
			font-size: 70px;
			line-height: 68.6px;
			letter-spacing: -3.5px;
		}

		.cts-visual {
			max-width: none;
		}
	}

	/* ---- appear ----
	   Only once the script has marked the root ready: no script, no hidden
	   state. The script never marks it under reduced motion, and this media
	   guard covers a preference that changes afterwards. */
	@media (prefers-reduced-motion: no-preference) {
		/* Transition only on the way in, so arming the hidden state is an
		   instant cut rather than a fade-out. */
		.cts[data-cts-ready] [data-in] .cts-feature,
		.cts[data-cts-ready] [data-in] .cts-kpi {
			transition:
				opacity 0.6s var(--cts-ease),
				transform 0.6s var(--cts-ease);
			transition-delay: calc(var(--cts-i, 0) * 90ms);
		}

		.cts[data-cts-ready] [data-cts-group]:not([data-in]) .cts-feature {
			opacity: 0;
			transform: translateX(-34px);
		}

		.cts[data-cts-ready] [data-cts-group]:not([data-in]) .cts-kpi {
			opacity: 0;
			transform: translateY(18px) scale(0.96);
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.cts-cta {
			transition: none;
		}

		.cts-cta:hover {
			transform: none;
		}
	}

	@media (forced-colors: active) {
		.cts-dash,
		.cts-kpi {
			border: 1px solid CanvasText;
		}

		.cts-bar {
			background: GrayText;
		}

		.cts-bar--on,
		.cts-check,
		.cts-dot {
			background: CanvasText;
		}
	}
</style>

<script>
	/**
	 * ControlTowerShowcase appear controller.
	 *
	 * One IntersectionObserver per instance, watching the feature list and
	 * the KPI stack. Each gets `data-in` the first time it enters the
	 * viewport and is then unobserved. Never scrolls, never moves focus.
	 */
	function initControlTower(root: HTMLElement) {
		if (root.dataset.ctsReady !== undefined) return;
		if (root.dataset.ctsAnimate !== "1") return;
		if (!("IntersectionObserver" in window)) return;
		if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

		const groups = [...root.querySelectorAll<HTMLElement>("[data-cts-group]")];
		if (groups.length === 0) return;

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

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					if (!entry.isIntersecting) continue;
					(entry.target as HTMLElement).dataset.in = "";
					observer.unobserve(entry.target);
				}
			},
			{ threshold: 0.2, rootMargin: "0px 0px -8% 0px" },
		);

		root.dataset.ctsReady = "";
		groups.forEach((group) => observer.observe(group));
		signal.addEventListener("abort", () => observer.disconnect());

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

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

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

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

===== END src/components/ControlTowerShowcase.astro =====

===== FILE (supporting): src/components/ControlTowerShowcase.md =====
# Control tower showcase

A dark product section. Left: lime-dot eyebrow, a huge white heading, a
paragraph, four ✓ feature rows and an "Explore the platform →" lime pill.
Right: a light rounded dashboard mock with a header ("Global operations /
Monday, 08:42 UTC"), a "SYSTEM HEALTHY" pill, a photo tile, and three white
KPI tiles: on-time 98.7% with a small bar chart, open exceptions 07 with a
week-on-week change, and CO₂ avoided 48.2t.

Catalog page: `/components/control-tower-showcase`

## Purpose

The "here is the software" section of a logistics or ops site. The dashboard
is an illustration, not a live widget.

## Files required

**`ControlTowerShowcase.astro`**, one file with one small script. No imports,
no npm packages. `public/control-tower-showcase/analytics-screen.webp` is the
catalog demo's photo tile (passed as `dashboard.image`; not a default).

## Basic usage

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

<ControlTowerShowcase
  heading="Your freight, live on one screen."
  features={["Delay forecasts", "Role-based views", "ERP integrations"]}
  cta={{ label: "See the platform", href: "/platform" }}
  dashboard={{
    timestamp: "Tuesday, 14:10 UTC",
    onTime: { label: "On-time performance", value: "96.1%" },
  }}
/>
```

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `""` | Drawn only when passed; never renders the section on its own. |
| `heading` | `string?` | `""` | Capped at ~6.4em wide so it breaks into three lines. Drawn only when passed. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | |
| `body` | `string?` | `""` | Drawn only when passed. |
| `features` | `string[]?` | `[]` | The ✓ rows. Blank rows are skipped. |
| `cta` | `{label, href} \| null` | `null` | Drawn only with both `label` and `href`. |
| `dashboard` | object | `{}` | Nothing is defaulted or merged: only what you pass is drawn. A figure (`onTime`, `exceptions`, `carbon`) needs both `label` and `value`. The panel appears only with a photo or at least one figure. |
| `animate` | `boolean?` | `true` | `false` renders at rest and skips the observer. |
| `background` | `string?` | `#08110f` | Section surface. |
| `fontStylesheet` | `string?` | Manrope + Inter | `""` skips the `<link>`. |
| `id` | `string?` | none | Root id. |

```ts
dashboard?: {
  title?: string;      // e.g. "Global operations"
  timestamp?: string;  // e.g. "Monday, 08:42 UTC": static text, not a clock
  status?: string;     // e.g. "System healthy" (omit to drop the pill)
  image?: { src?: string; alt?: string } | null; // omitted/null = no tile, src "" = placeholder
  onTime?: { label: string; value: string; bars?: number[]; highlight?: number[] };
  exceptions?: { label: string; value: string; delta?: string };
  carbon?: { label: string; value: string };
}
```

`bars` are relative heights (the demo passes `[25,45,32,52,58,48]`, the
measured pixels); `highlight` lists the indexes drawn lime (demo `[1, 4]`).
Neither has a default: no `bars`, no chart.

With no props the component renders nothing.

## Behaviour

- **Appear on scroll (measured):** the feature rows go from `opacity 0,
  translateX(-34px)` to rest; the KPI tiles from `opacity 0, scale(.96)
  translateY(18px)` to rest. Each group animates once when it enters the
  viewport, items staggered 90ms, 0.6s `cubic-bezier(.22,1,.36,1)`.
  Implementation: one `IntersectionObserver` per instance sets `data-in` on
  the list or stack. The hidden state is CSS scoped to
  `.cts[data-cts-ready]`, and the script sets that only when motion is
  allowed. So no JS, reduced motion or `animate={false}` all show everything
  at rest. The transition applies only on the way in, so arming the hidden
  state is an instant cut, not a fade-out.
- **Hover (measured):** "Explore the platform" goes to `translateY(-2px)
  scale(1.1)`, 0.3s ease-out. Dropped under reduced motion.
- **Timestamp:** static text from `dashboard.timestamp`.
- **Lifecycle:** runs on load and on `astro:page-load`, idempotent per root
  (`data-cts-ready`), observer disconnected via an `AbortController` on
  `astro:before-swap`. Never scrolls the window or moves focus.

## Accessibility

- Features are a `<ul>`; check discs are `aria-hidden`.
- The dashboard is `role="group"` named "Global operations dashboard
  (illustration)". Its tiles are a list of label + value paragraphs, so the
  figures read in order. The bar chart is `aria-hidden` (the value above it
  carries the meaning).
- The pill link is 52px tall with a 2px white `:focus-visible` outline.
- Under `forced-colors: active` the panel and tiles get borders, and the
  bars and checks use system colours.

## Responsive

| Width | Layout | Padding | Heading |
| :--- | :--- | :--- | :--- |
| ≥ 1200 | 2 columns, 584px each, 72px gap | 120/48 | 70/68.6, −3.5px |
| 810–1199 | stacked; dashboard max 640px, photo + KPI stack side by side | 100/40 | 62px |
| < 810 | stacked; photo 250px tall, tiles full width | 82/18, 52px gap | 52px (42px at 320) |

## Integration notes

- Full-bleed section with its own surface and padding.
- Reads `--nv-ink`, `--nv-lime` (fallback `#b7ff39`, the measured lime for
  this section) and `--nv-font-ui`. Setting `--nv-lime: #c8ff38` on the page
  shifts the lime a touch.
- The heading, like everything in this section, uses **Inter** (the dump
  measures Inter here, not Manrope).

## Images and credits

| File | Slot | Size | Credit |
| :--- | :--- | :--- | :--- |
| `public/control-tower-showcase/analytics-screen.webp` | dashboard photo tile | 900×1080 | Photo: Unsplash JKUTrJ4vK00, Unsplash License |

Lazy-loaded, `object-fit: cover`; the demo's alt is "Monitor showing shipment
analytics charts". `image.src: ""` renders a striped "Image" placeholder (with
the `alt` text when one is given) and "5:6 · 900 × 1080 px".

## Approximations

- **Appear timing** (0.6s, 90ms stagger, ease-out curve, threshold 0.2) is
  not in the dump; only the start/end states were measured.
- **KPI tiles share the photo's 420px height** (`flex: 1 1 auto`). In the
  reference the stack ran 14px past the photo and the dashboard clipped it.
- **Tablet is interpolated** (no capture): stacked, dashboard capped at 640px.
- **Phone heading 52px** is derived from the phone screenshot's line width;
  the phone dump didn't include text nodes.
- The heading's three-line break comes from a width cap plus
  `text-wrap: balance`, not forced `<br>`s, so other copy breaks naturally.

===== END src/components/ControlTowerShowcase.md =====

===== FILE (required): public/control-tower-showcase/analytics-screen.webp =====
[binary asset — download https://astro.baysixmedia.com/control-tower-showcase/analytics-screen.webp and save it to public/control-tower-showcase/analytics-screen.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
“Control tower showcase” from my Astro Component Library:
https://astro.baysixmedia.com/components/control-tower-showcase/

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
2 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/ControlTowerShowcase.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/control-tower-showcase/analytics-screen.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * ControlTowerShowcase
 *
 * A dark product section. Left: lime-dot eyebrow, a huge white heading, a
 * paragraph, a list of features with lime check discs, and a lime pill link.
 * Right: a light, rounded dashboard mock — a header with a timestamp and a
 * status pill, a photo tile, and a stack of three white KPI tiles (a small
 * bar chart, a figure with a week-on-week change, and a plain figure).
 *
 * Appear on scroll: the feature rows slide in from the left and the KPI tiles
 * rise and scale in, both staggered, once. The hidden starting state is only
 * applied after the script has marked the root ready, so with no script or
 * under reduced motion everything renders at rest.
 *
 * Two columns from 1200px, stacked below. Reads --nv-* tokens, with fallbacks.
 */

export interface ControlTowerShowcaseProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	body?: string;
	features?: string[];
	cta?: { label: string; href: string } | null;
	dashboard?: {
		title?: string;
		/** Static text. Not a live clock. */
		timestamp?: string;
		/** Status pill text. `""` drops the pill. */
		status?: string;
		/** Photo tile. `src: ""` renders the labelled placeholder. `null` drops the tile. */
		image?: { src?: string; alt?: string } | null;
		onTime?: { label: string; value: string; bars?: number[]; highlight?: number[] };
		exceptions?: { label: string; value: string; delta?: string };
		carbon?: { label: string; value: string };
	};
	/** The scroll-in motion. `false` renders everything at rest. */
	animate?: boolean;
	background?: string;
	fontStylesheet?: string;
	id?: string;
}

interface Props extends ControlTowerShowcaseProps {}

/*
 * No demo dashboard and nothing merged into one: the title, timestamp,
 * status, photo and every figure are drawn only when the caller passes them.
 * A KPI needs both a label and a value; a partial one is not drawn — never an
 * invented 98.7% beside real figures, and never one on a page that passed
 * none. (The catalog demo passes its own dashboard through the registry.)
 */
const {
	eyebrow = "",
	heading = "",
	headingLevel = "h2",
	body = "",
	features = [],
	cta = null,
	dashboard = {},
	animate = true,
	background = "#08110f",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700;800&display=swap",
	id,
} = Astro.props;

const filled = (v: unknown): v is string => typeof v === "string" && v.trim() !== "";
const stat = <T extends { label?: string; value?: string }>(s: T | undefined | null) =>
	s && filled(s.label) && filled(s.value) ? s : undefined;

const raw = dashboard ?? {};
const d = {
	title: raw.title,
	timestamp: raw.timestamp,
	status: raw.status,
	onTime: stat(raw.onTime),
	exceptions: stat(raw.exceptions),
	carbon: stat(raw.carbon),
};
const image = raw.image ? { src: raw.image.src ?? "", alt: raw.image.alt ?? "" } : null;
const hasKpis = Boolean(d.onTime || d.exceptions || d.carbon);
/* The dashboard needs something in its body — a photo or a figure. A title,
   timestamp or status pill alone does not draw an empty panel. */
const showDash = Boolean(image) || hasKpis;
const bars = (d.onTime?.bars ?? []).filter((n) => Number.isFinite(n) && n > 0);
const barMax = Math.max(1, ...bars);
const highlight = new Set(d.onTime?.highlight ?? []);
const Heading = headingLevel;
const featureList = Array.isArray(features) ? features.filter(filled) : [];
const link = cta && filled(cta.label) && filled(cta.href) ? cta : null;
/* The eyebrow alone never renders the section. */
const hasContent = filled(heading) || filled(body) || featureList.length > 0 || Boolean(link) || showDash;
---

{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<section class="cts" id={id} style={`--cts-bg: ${background};`} data-cts-root data-cts-animate={animate ? "1" : "0"}>
	<div class="cts-grid">
		<div class="cts-content">
			{
				eyebrow && (
					<p class="cts-eyebrow">
						<span class="cts-dot" aria-hidden="true" />
						{eyebrow}
					</p>
				)
			}
			{heading && <Heading class="cts-heading">{heading}</Heading>}
			{body && <p class="cts-body">{body}</p>}
			{
				featureList.length > 0 && (
					<ul class="cts-features" role="list" data-cts-group>
						{featureList.map((f, i) => (
							<li class="cts-feature" style={`--cts-i:${i}`}>
								<span class="cts-check" aria-hidden="true">
									<svg viewBox="0 0 16 16" focusable="false">
										<path d="M3.5 8.4l2.9 2.9 6-6.3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
									</svg>
								</span>
								<span>{f}</span>
							</li>
						))}
					</ul>
				)
			}
			{
				link && (
					<a class="cts-cta" href={link.href}>
						<span>{link.label}</span>
						<svg viewBox="0 0 18 18" aria-hidden="true" focusable="false">
							<path d="M3.5 9h11M10 4.5L14.5 9 10 13.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
						</svg>
					</a>
				)
			}
		</div>

		{showDash && (
		<div class="cts-visual">
			<div class="cts-dash" role="group" aria-label={d.title ? `${d.title} dashboard (illustration)` : "Dashboard illustration"}>
				<div class="cts-dash-head">
					<div class="cts-dash-id">
						{d.title && <p class="cts-dash-title">{d.title}</p>}
						{d.timestamp && <p class="cts-dash-time">{d.timestamp}</p>}
					</div>
					{
						d.status && (
							<p class="cts-status">
								<span class="cts-dot cts-dot--sm" aria-hidden="true" />
								{d.status}
							</p>
						)
					}
				</div>
				<div class:list={["cts-dash-main", { "cts-dash-main--noimg": !image }]}>
					{
						image && (
							<div class="cts-photo">
								{image.src ? (
									<img src={image.src} alt={image.alt ?? ""} width="900" height="1080" loading="lazy" decoding="async" />
								) : (
									<div class="cts-ph" role="img" aria-label={image.alt ? `Image placeholder: ${image.alt}` : "Image placeholder"}>
										<span class="cts-ph-text" aria-hidden="true">
											<span class="cts-ph-label">Image</span>
											{image.alt && <span>{image.alt}</span>}
											<span class="cts-ph-spec">5:6 · 900 × 1080 px</span>
										</span>
									</div>
								)}
							</div>
						)
					}
					{hasKpis && (
						<ul class="cts-kpis" role="list" data-cts-group>
							{
								d.onTime && (
									<li class="cts-kpi" style="--cts-i:0">
										<p class="cts-kpi-label">{d.onTime.label}</p>
										<p class="cts-kpi-value">{d.onTime.value}</p>
										{bars.length > 0 && (
											<div class="cts-bars" aria-hidden="true">
												{bars.map((b, i) => (
													<span
														class:list={["cts-bar", { "cts-bar--on": highlight.has(i) }]}
														style={`height:${Math.round((b / barMax) * 100)}%`}
													/>
												))}
											</div>
										)}
									</li>
								)
							}
							{
								d.exceptions && (
									<li class="cts-kpi" style="--cts-i:1">
										<p class="cts-kpi-label">{d.exceptions.label}</p>
										<p class="cts-kpi-value">{d.exceptions.value}</p>
										{d.exceptions.delta && <p class="cts-kpi-delta">{d.exceptions.delta}</p>}
									</li>
								)
							}
							{
								d.carbon && (
									<li class="cts-kpi" style="--cts-i:2">
										<p class="cts-kpi-label">{d.carbon.label}</p>
										<p class="cts-kpi-value">{d.carbon.value}</p>
									</li>
								)
							}
						</ul>
					)}
				</div>
			</div>
		</div>
		)}
	</div>
</section>
)}

<style>
	.cts {
		--cts-ink: var(--nv-ink, #08110f);
		--cts-lime: var(--nv-lime, #b7ff39);
		--cts-font: var(--nv-font-ui, Inter, ui-sans-serif, system-ui, sans-serif);
		--cts-ease: cubic-bezier(0.22, 1, 0.36, 1);

		box-sizing: border-box;
		padding: 82px 18px;
		background: var(--cts-bg);
		color: #fff;
		font-family: var(--cts-font);
		overflow: clip;
	}

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

	.cts p {
		margin: 0;
	}

	.cts-grid {
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		gap: 52px;
		max-width: 1240px;
		margin: 0 auto;
	}

	.cts-content {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 24px;
		min-width: 0;
	}

	.cts-eyebrow {
		display: flex;
		align-items: center;
		gap: 9px;
		font: 700 12px/14.4px var(--cts-font);
		letter-spacing: 1.56px;
		text-transform: uppercase;
		color: #fff;
	}

	.cts-dot {
		flex: none;
		width: 7px;
		height: 7px;
		border-radius: 100px;
		background: var(--cts-lime);
	}

	.cts-heading {
		max-width: 6.4em;
		margin: 0;
		font-weight: 700;
		font-size: clamp(42px, 13.4vw, 52px);
		line-height: 0.98;
		letter-spacing: -0.05em;
		color: #fff;
		text-wrap: balance;
	}

	.cts-body {
		max-width: 550px;
		font: 400 17px/28.9px var(--cts-font);
		color: rgb(255 255 255 / 0.65);
	}

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

	.cts-feature {
		display: flex;
		align-items: center;
		gap: 13px;
		font: 600 14px/16.8px var(--cts-font);
		color: #fff;
	}

	.cts-check {
		display: grid;
		place-items: center;
		flex: none;
		width: 28px;
		height: 28px;
		border-radius: 100px;
		background: var(--cts-lime);
		color: var(--cts-ink);
	}

	.cts-check svg {
		width: 15px;
		height: 15px;
	}

	.cts-cta {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		gap: 10px;
		height: 52px;
		padding: 0 21px;
		border-radius: 100px;
		background: var(--cts-lime);
		color: var(--cts-ink);
		font: 700 13px/15.6px var(--cts-font);
		text-decoration: none;
		transition: transform 0.3s var(--cts-ease);
	}

	.cts-cta svg {
		width: 17px;
		height: 17px;
	}

	.cts-cta:hover {
		transform: translateY(-2px) scale(1.1);
	}

	.cts-cta:focus-visible {
		outline: 2px solid #fff;
		outline-offset: 3px;
	}

	/* ---- dashboard mock ---- */

	.cts-visual {
		min-width: 0;
	}

	.cts-dash {
		display: flex;
		flex-direction: column;
		gap: 14px;
		padding: 20px;
		border-radius: 32px;
		background: #f4f6f2;
		color: var(--cts-ink);
		box-shadow: 0 24px 60px 0 rgb(0 0 0 / 0.32);
		overflow: clip;
	}

	.cts-dash-head {
		display: flex;
		align-items: center;
		justify-content: space-between;
		flex-wrap: wrap;
		gap: 10px;
		padding: 8px 5px 19px;
	}

	.cts-dash-id {
		display: flex;
		flex-direction: column;
		gap: 3px;
	}

	.cts-dash-title {
		font: 400 12px/14.4px var(--cts-font);
	}

	.cts-dash-time {
		font: 700 16px/19.2px var(--cts-font);
	}

	.cts-status {
		display: inline-flex;
		align-items: center;
		gap: 7px;
		height: 30px;
		padding: 0 11px;
		border-radius: 100px;
		background: rgb(177 255 57 / 0.28);
		font: 700 10px/12px var(--cts-font);
		letter-spacing: 0.5px;
		text-transform: uppercase;
		white-space: nowrap;
	}

	.cts-dash-main {
		display: flex;
		flex-direction: column;
		gap: 14px;
	}

	.cts-photo {
		position: relative;
		height: 250px;
		border-radius: 22px;
		overflow: hidden;
		background: #0d1614;
	}

	.cts-photo img {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	.cts-ph {
		display: grid;
		place-items: center;
		width: 100%;
		height: 100%;
		padding: 16px;
		text-align: center;
		background: repeating-linear-gradient(135deg, #e3e7e1 0 12px, #ebeee9 12px 24px);
		color: rgb(8 17 15 / 0.62);
	}

	.cts-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 6px;
		font: 500 12px/1.35 var(--cts-font);
	}

	.cts-ph-label {
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.cts-ph-spec {
		font-weight: 400;
	}

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

	.cts-kpi {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 8px;
		padding: 18px;
		border-radius: 20px;
		background: #fff;
	}

	.cts-kpi-label {
		font: 500 12px/14.4px var(--cts-font);
		color: rgb(8 17 15 / 0.58);
	}

	.cts-kpi-value {
		font: 700 28px/33.6px var(--cts-font);
		color: var(--cts-ink);
	}

	.cts-kpi-delta {
		margin-top: -1px;
		font: 600 12px/14.4px var(--cts-font);
		color: #288047;
	}

	.cts-bars {
		display: flex;
		align-items: flex-end;
		gap: 7px;
		width: 100%;
		height: 58px;
		margin-top: 4px;
	}

	.cts-bar {
		flex: 1 1 0;
		min-width: 0;
		border-radius: 5px 5px 0 0;
		background: rgb(125 145 133 / 0.28);
	}

	.cts-bar--on {
		background: var(--cts-lime);
	}

	/* ---- tablet: still stacked, dashboard side by side inside ---- */
	@media (min-width: 810px) {
		.cts {
			padding: 100px 40px;
		}

		.cts-heading {
			font-size: 62px;
		}

		.cts-visual {
			max-width: 640px;
		}

		.cts-dash-main {
			flex-direction: row;
			align-items: stretch;
		}

		.cts-photo {
			flex: 1 1 auto;
			height: auto;
			min-height: 420px;
		}

		.cts-kpis {
			flex: 0 0 220px;
		}

		/* Tiles share out the photo's height, as the reference's stack does. */
		.cts-kpi {
			flex: 1 1 auto;
		}

		.cts-dash-main--noimg .cts-kpis {
			flex: 1 1 auto;
		}
	}

	/* ---- desktop: two columns ---- */
	@media (min-width: 1200px) {
		.cts {
			padding: 120px 48px;
		}

		.cts-grid {
			grid-template-columns: repeat(2, minmax(0, 584px));
			justify-content: center;
			align-items: start;
			gap: 72px;
		}

		.cts-heading {
			font-size: 70px;
			line-height: 68.6px;
			letter-spacing: -3.5px;
		}

		.cts-visual {
			max-width: none;
		}
	}

	/* ---- appear ----
	   Only once the script has marked the root ready: no script, no hidden
	   state. The script never marks it under reduced motion, and this media
	   guard covers a preference that changes afterwards. */
	@media (prefers-reduced-motion: no-preference) {
		/* Transition only on the way in, so arming the hidden state is an
		   instant cut rather than a fade-out. */
		.cts[data-cts-ready] [data-in] .cts-feature,
		.cts[data-cts-ready] [data-in] .cts-kpi {
			transition:
				opacity 0.6s var(--cts-ease),
				transform 0.6s var(--cts-ease);
			transition-delay: calc(var(--cts-i, 0) * 90ms);
		}

		.cts[data-cts-ready] [data-cts-group]:not([data-in]) .cts-feature {
			opacity: 0;
			transform: translateX(-34px);
		}

		.cts[data-cts-ready] [data-cts-group]:not([data-in]) .cts-kpi {
			opacity: 0;
			transform: translateY(18px) scale(0.96);
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.cts-cta {
			transition: none;
		}

		.cts-cta:hover {
			transform: none;
		}
	}

	@media (forced-colors: active) {
		.cts-dash,
		.cts-kpi {
			border: 1px solid CanvasText;
		}

		.cts-bar {
			background: GrayText;
		}

		.cts-bar--on,
		.cts-check,
		.cts-dot {
			background: CanvasText;
		}
	}
</style>

<script>
	/**
	 * ControlTowerShowcase appear controller.
	 *
	 * One IntersectionObserver per instance, watching the feature list and
	 * the KPI stack. Each gets `data-in` the first time it enters the
	 * viewport and is then unobserved. Never scrolls, never moves focus.
	 */
	function initControlTower(root: HTMLElement) {
		if (root.dataset.ctsReady !== undefined) return;
		if (root.dataset.ctsAnimate !== "1") return;
		if (!("IntersectionObserver" in window)) return;
		if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

		const groups = [...root.querySelectorAll<HTMLElement>("[data-cts-group]")];
		if (groups.length === 0) return;

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

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					if (!entry.isIntersecting) continue;
					(entry.target as HTMLElement).dataset.in = "";
					observer.unobserve(entry.target);
				}
			},
			{ threshold: 0.2, rootMargin: "0px 0px -8% 0px" },
		);

		root.dataset.ctsReady = "";
		groups.forEach((group) => observer.observe(group));
		signal.addEventListener("abort", () => observer.disconnect());

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

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

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

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