Component library
Back to catalog

Service wayfinder

Onward navigation for the foot of a service page: a filled 'next' sign with the next service set large and a solid arrow, a quieter 'previous' sign, and a route line with every service as a stop and this page marked 'You are here'. Takes the business's whole service list; renders nothing without two services and a known current page.

servicesnext serviceprevious servicepagerroutewayfindingservice pagedarklocal service

Open preview in a new tab

Use this component

Three ways to get Service wayfinder 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 1 file 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 Service wayfinder from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/service-wayfinder/

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 1 file 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: "Service wayfinder"
Library page: https://astro.baysixmedia.com/components/service-wayfinder/
Files: 1 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/ServiceWayfinder.astro =====
---
/**
 * ServiceWayfinder (candidate)
 *
 * Onward navigation at the foot of a service page. It is not a list of what
 * the business does: it tells the visitor where they are among the business's
 * service pages and where to go next, the way a transit sign does.
 *
 *   - A header line: an optional heading, and the position as "03 / 06".
 *   - Two signs: a narrow "previous" sign and a wide, filled "next" sign —
 *     the next service's name set large, one line about it, a solid arrow.
 *   - A route line under them: every service as a stop on one drawn line,
 *     this page marked "you are here", the leg to the next stop drawn in the
 *     sign colour. Each other stop links to its page.
 *
 * Truth rules
 * - Everything shown comes from the supplied list. The position and count are
 *   derived from it; no service, line or link is invented.
 * - Fewer than two services, or no way to tell which one this page is, and
 *   nothing renders. `current` (1-based) says which; omitted, the component
 *   matches a service's root-relative href against the page URL.
 * - Links go through safeHref (http(s), mailto, tel, #, relative). A service
 *   without a usable href is a plain stop and, as a sign, a plain sign with no
 *   arrow — never a fake link.
 * - `wrap` (default true): from the last service "next" is the first. With
 *   wrap off, the last page's next sign is `indexLink` when one is passed,
 *   otherwise the previous sign takes the width.
 *
 * Responsive (container queries on the component's own width)
 * - < 700px: previous as a slim sign row, next as a full-width block, the
 *   route as a vertical line with the stops down it.
 * - ≥ 700px: the two signs side by side (1 : 2.1), the route horizontal when
 *   it holds six stops or fewer; seven to eight go horizontal from 960px,
 *   nine or more from 1100px.
 * - ≥ 960px: the next service's name up to 84px (stepped down for long
 *   names), a 96px arrow; content edge on the site grid.
 *
 * Interaction: no script. Hover or keyboard focus on a sign moves its arrow
 * along and underlines the name; the whole sign carries the focus ring.
 * Transform only; nothing moves under reduced motion.
 *
 * Hooks read (never declared): --swf-bg, --swf-panel, --swf-text,
 * --swf-text-muted, --swf-brand, --swf-brand-text, --swf-border, --swf-rail,
 * --swf-focus, --swf-family, --swf-grid-max, --swf-grid-gutter.
 */

export interface ServiceWayfinderStop {
	/** The service name, as supplied. */
	title: string;
	/** One line about the service; shown when it is the next stop. */
	description?: string;
	/** The service's own page. Without a usable one the stop is plain. */
	href?: string;
}

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

export interface ServiceWayfinderProps {
	/** Optional line over the signs, e.g. "More from …". Names the navigation when given. */
	heading?: string;
	headingLevel?: "h2" | "h3";
	/** Accessible name for the navigation when there is no heading. */
	navLabel?: string;
	/** Every service, in the order the site presents them. */
	services?: readonly ServiceWayfinderStop[];
	/** This page's service, 1-based. Omitted: matched by href against the page URL. */
	current?: number;
	/** From the last service, "next" is the first. */
	wrap?: boolean;
	/** The services hub. Shown beside the position; replaces the next sign at the end of an unwrapped route. */
	indexLink?: ServiceWayfinderLink;
	nextLabel?: string;
	previousLabel?: string;
	hereLabel?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends ServiceWayfinderProps {}

const {
	heading = "",
	headingLevel = "h2",
	navLabel = "Services",
	services = [],
	current,
	wrap = true,
	indexLink,
	nextLabel = "Next service",
	previousLabel = "Previous service",
	hereLabel = "You are here",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Overpass:wght@400;500;600;700;800&display=swap",
	class: className,
	id,
} = Astro.props as Props;

const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
/* An unsafe or empty link is dropped, never replaced with "#". */
const safeHref = (href: string | undefined) => (href && SAFE.test(href.trim()) ? href.trim() : "");
const pad = (n: number) => String(n).padStart(2, "0");

const stops = (services ?? [])
	.filter((s) => s && typeof s.title === "string" && s.title.trim())
	.map((s, i) => ({
		title: s.title.trim(),
		description: s.description?.trim() ?? "",
		href: safeHref(s.href),
		num: pad(i + 1),
	}));

/* Which stop is this page. */
const path = (p: string) => p.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
let here = -1;
if (typeof current === "number") {
	if (Number.isInteger(current) && current >= 1 && current <= stops.length) here = current - 1;
} else {
	const page = path(Astro.url?.pathname ?? "");
	here = stops.findIndex((s) => s.href.startsWith("/") && !s.href.startsWith("//") && path(s.href) === page);
}

const show = stops.length >= 2 && here >= 0;

let nextIdx = here + 1 < stops.length ? here + 1 : wrap ? 0 : -1;
let prevIdx = here - 1 >= 0 ? here - 1 : wrap ? stops.length - 1 : -1;
/* Two services: the one other service is next, never both next and previous. */
if (prevIdx === nextIdx) prevIdx = -1;

const hub = indexLink && indexLink.label?.trim() && safeHref(indexLink.href) ? { label: indexLink.label.trim(), href: safeHref(indexLink.href) } : null;

const nextStop = show && nextIdx >= 0 ? stops[nextIdx] : null;
const prevStop = show && prevIdx >= 0 ? stops[prevIdx] : null;
/* At the end of an unwrapped route the hub takes the next sign's place. */
const hubSign = show && !nextStop && hub ? hub : null;
const headHub = hub && !hubSign ? hub : null;

const nextName = nextStop?.title ?? hubSign?.label ?? "";
/* Long names step down so they stay a display line or two, not a wall of type. */
const fit = nextName.length > 28 ? "longer" : nextName.length > 18 ? "long" : "";
/* The drawn leg from here to the next stop, when the next stop is the adjacent one. */
const leg = nextIdx === here + 1;
const routeSize = stops.length <= 6 ? "s" : stops.length <= 8 ? "m" : "l";
const solo = !(prevStop && (nextStop || hubSign));
const Heading = headingLevel;
const label = heading.trim();
---

{show && (
<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<nav
	class:list={["swf", `swf--route-${routeSize}`, className]}
	id={id}
	aria-label={label || navLabel}
	style={`--swf-legs: ${stops.length - 1}`}
>
	<div class="swf-inner">
		<div class="swf-head">
			{label && <Heading class="swf-heading">{label}</Heading>}
			<div class="swf-head-aside">
				<p class="swf-count">
					<span class="swf-count-here">{stops[here].num}</span>
					<span class="swf-count-sep"> / </span>
					<span>{pad(stops.length)}</span>
				</p>
				{headHub && (
					<a class="swf-hub" href={headHub.href}>
						<span>{headHub.label}</span>
						<svg class="swf-hub-arrow" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><path d="M4 20h26V9l16 15-16 15V28H4z" /></svg>
					</a>
				)}
			</div>
		</div>

		<div class:list={["swf-pair", { "swf-pair--solo": solo }]}>
			{prevStop && (
				<div class:list={["swf-sign", "swf-sign--prev", { "swf-sign--link": prevStop.href }]}>
					{prevStop.href && (
						<svg class="swf-arrow swf-arrow--back" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><path d="M4 20h26V9l16 15-16 15V28H4z" /></svg>
					)}
					{prevStop.href ? (
						<a class="swf-sign-link" href={prevStop.href}>
							<span class="swf-kicker">{previousLabel}</span>{" "}
							<span class="swf-prev-name">{prevStop.title}</span>
						</a>
					) : (
						<p class="swf-sign-link">
							<span class="swf-kicker">{previousLabel}</span>{" "}
							<span class="swf-prev-name">{prevStop.title}</span>
						</p>
					)}
				</div>
			)}

			{(nextStop || hubSign) && (
				<div class:list={["swf-sign", "swf-sign--next", { "swf-sign--link": nextStop ? nextStop.href : true, [`swf-sign--${fit}`]: fit }]}>
					{nextStop && <span class="swf-sign-num" aria-hidden="true">{nextStop.num}</span>}
					{nextStop ? (
						nextStop.href ? (
							<a class="swf-sign-link" href={nextStop.href}>
								<span class="swf-kicker">{nextLabel}</span>{" "}
								<span class="swf-next-name">{nextStop.title}</span>
							</a>
						) : (
							<p class="swf-sign-link">
								<span class="swf-kicker">{nextLabel}</span>{" "}
								<span class="swf-next-name">{nextStop.title}</span>
							</p>
						)
					) : (
						<a class="swf-sign-link" href={hubSign!.href}>
							<span class="swf-next-name">{hubSign!.label}</span>
						</a>
					)}
					{nextStop?.description && <p class="swf-next-desc">{nextStop.description}</p>}
					{(hubSign || nextStop?.href) && (
						<svg class="swf-arrow swf-arrow--next" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><path d="M4 20h26V9l16 15-16 15V28H4z" /></svg>
					)}
				</div>
			)}
		</div>

		<ol class="swf-route" role="list">
			{stops.map((s, i) => {
				const isHere = i === here;
				/* This page is marked, not linked; a stop without a usable href is plain text. */
				const Tag = isHere || !s.href ? "span" : "a";
				return (
					<li class:list={["swf-stop", { "swf-stop--here": isHere, "swf-stop--next": i === nextIdx && !isHere, "swf-stop--leg": isHere && leg, "swf-stop--plain": !isHere && !s.href }]}>
						<Tag class="swf-stop-link" href={Tag === "a" ? s.href : undefined} aria-current={isHere ? "page" : undefined}>
							<span class="swf-dot" aria-hidden="true"></span>
							<span class="swf-stop-num">{s.num}</span>{" "}
							<span class="swf-stop-name">{s.title}</span>{" "}
							{isHere && <span class="swf-stop-here">{hereLabel}</span>}
						</Tag>
					</li>
				);
			})}
		</ol>
	</div>
</nav>
</>
)}

<style>
	.swf {
		--swf-surface: var(--swf-bg, #13212b);
		--swf-raised: var(--swf-panel, #1c2d39);
		--swf-ink: var(--swf-text, #f3f5f2);
		--swf-soft: var(--swf-text-muted, #bcc7cf);
		--swf-sign: var(--swf-brand, #f4cf3b);
		--swf-sign-ink: var(--swf-brand-text, #13212b);
		--swf-edge: var(--swf-border, #6d808e);
		--swf-line: var(--swf-rail, #6d808e);
		--swf-ring: var(--swf-focus, #ffffff);
		--swf-font: var(--swf-family, "Overpass", ui-sans-serif, system-ui, sans-serif);

		/* The route's dot centre, shared by the dot and the drawn line. */
		--swf-dot-c: 12px;
		--swf-rail-w: 3px;

		container-type: inline-size;
		display: block;
		background: var(--swf-surface);
		color: var(--swf-ink);
		font-family: var(--swf-font);
	}

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

	.swf-inner {
		max-width: var(--swf-grid-max, 1232px);
		margin: 0 auto;
		padding: 48px 20px 56px;
	}

	/* ---- header line: heading and position ---- */

	.swf-head {
		display: flex;
		flex-wrap: wrap;
		align-items: baseline;
		justify-content: space-between;
		gap: 8px 24px;
		margin-bottom: 20px;
	}

	.swf-heading {
		flex: 1 1 16rem;
		min-width: 0;
		margin: 0;
		color: var(--swf-ink);
		font: 700 19px/1.3 var(--swf-font);
		letter-spacing: -0.005em;
		overflow-wrap: break-word;
		text-wrap: balance;
	}

	.swf-head-aside {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		gap: 4px 20px;
	}

	.swf-count {
		margin: 0;
		color: var(--swf-soft);
		font: 700 19px/1 var(--swf-font);
		font-variant-numeric: tabular-nums;
		white-space: nowrap;
	}

	.swf-count-here {
		color: var(--swf-sign);
	}

	.swf-hub {
		display: inline-flex;
		align-items: center;
		gap: 8px;
		min-height: 44px;
		color: var(--swf-ink);
		font: 600 16px/1.2 var(--swf-font);
		text-decoration: underline;
		text-decoration-thickness: 2px;
		text-underline-offset: 5px;
		text-decoration-color: var(--swf-edge);
		border-radius: 4px;
	}

	.swf-hub:hover {
		text-decoration-color: var(--swf-sign);
	}

	.swf-hub:focus-visible {
		outline: 3px solid var(--swf-ring);
		outline-offset: 3px;
	}

	.swf-hub-arrow {
		flex: none;
		width: 16px;
		height: 16px;
		fill: currentColor;
	}

	/* ---- the signs ---- */

	.swf-pair {
		display: grid;
		gap: 12px;
	}

	.swf-sign {
		position: relative;
		min-width: 0;
		border-radius: 6px;
	}

	.swf-sign-link {
		display: grid;
		gap: 6px;
		min-width: 0;
		margin: 0;
		color: inherit;
		text-decoration: none;
	}

	/* The link's box covers its sign: one target, and the accessible name is kicker + service. */
	a.swf-sign-link::after {
		content: "";
		position: absolute;
		inset: 0;
		z-index: 1;
		border-radius: inherit;
	}

	.swf-sign--link {
		cursor: pointer;
	}

	/* The ring sits on the focused link itself, round the kicker and the name. */
	.swf-sign-link:focus-visible {
		outline: 3px solid var(--swf-ring);
		outline-offset: 6px;
		border-radius: 4px;
	}

	.swf-kicker {
		font: 700 15px/1.3 var(--swf-font);
		letter-spacing: 0.005em;
	}

	.swf-arrow {
		flex: none;
		fill: currentColor;
		transition: transform 0.3s cubic-bezier(0.22, 0.61, 0.36, 1);
	}

	/* previous: a slim row on phones */

	.swf-sign--prev {
		display: grid;
		grid-template-columns: auto minmax(0, 1fr);
		align-items: center;
		gap: 14px;
		min-height: 64px;
		padding: 12px 16px;
		background: var(--swf-raised);
		border: 2px solid var(--swf-edge);
		color: var(--swf-ink);
	}

	.swf-sign--prev:not(:has(.swf-arrow)) {
		grid-template-columns: minmax(0, 1fr);
	}

	.swf-sign--prev .swf-kicker {
		color: var(--swf-soft);
		font-size: 14px;
	}

	.swf-arrow--back {
		width: 28px;
		height: 28px;
		transform: scaleX(-1);
	}

	.swf-prev-name {
		font: 700 18px/1.2 var(--swf-font);
		letter-spacing: -0.01em;
		overflow-wrap: break-word;
		text-wrap: balance;
	}

	/* next: the filled sign */

	.swf-sign--next {
		--swf-fit: 1;

		display: grid;
		grid-template-columns: minmax(0, 1fr) auto;
		grid-template-areas:
			"link link"
			"desc arrow";
		align-items: end;
		gap: 14px 16px;
		padding: 22px 20px 20px;
		background: var(--swf-sign);
		color: var(--swf-sign-ink);
	}

	.swf-sign--long {
		--swf-fit: 0.8;
	}

	.swf-sign--longer {
		--swf-fit: 0.64;
	}

	.swf-sign--next .swf-sign-link {
		grid-area: link;
		align-self: start;
		gap: 10px;
	}

	.swf-sign--next .swf-kicker {
		padding-right: 44px;
	}

	/* Phones: pinned in the corner so the name keeps the sign's full width. */
	.swf-sign-num {
		position: absolute;
		top: 22px;
		right: 20px;
		grid-area: num;
		align-self: start;
		font: 800 18px/1 var(--swf-font);
		font-variant-numeric: tabular-nums;
	}

	.swf-next-name {
		font-weight: 800;
		font-size: max(26px, calc(clamp(34px, 9.5cqi, 44px) * var(--swf-fit)));
		line-height: 1;
		letter-spacing: -0.03em;
		overflow-wrap: break-word;
		text-wrap: balance;
		text-decoration-line: underline;
		text-decoration-thickness: 0.07em;
		text-underline-offset: 0.12em;
		text-decoration-color: transparent;
		transition: text-decoration-color 0.2s ease;
	}

	.swf-next-desc {
		grid-area: desc;
		align-self: start;
		max-width: 46ch;
		margin: 0;
		font: 500 17px/1.45 var(--swf-font);
		text-wrap: pretty;
		overflow-wrap: break-word;
	}

	.swf-arrow--next {
		grid-area: arrow;
		width: 44px;
		height: 44px;
	}

	/* No line under the name: the arrow sits under it, at the end. */
	.swf-sign--next:not(:has(.swf-next-desc)) {
		grid-template-areas:
			"link link"
			". arrow";
	}

	/* hover and focus: the arrow moves on, the name is underlined */

	.swf-sign--link:hover .swf-arrow--next,
	.swf-sign--link:focus-within .swf-arrow--next {
		transform: translateX(8px);
	}

	.swf-sign--link:hover .swf-arrow--back,
	.swf-sign--link:focus-within .swf-arrow--back {
		transform: scaleX(-1) translateX(6px);
	}

	.swf-sign--link:hover .swf-next-name,
	.swf-sign--link:focus-within .swf-next-name {
		text-decoration-color: currentColor;
	}

	.swf-sign--prev.swf-sign--link:hover {
		border-color: var(--swf-sign);
	}

	.swf-sign--prev.swf-sign--link:hover .swf-prev-name,
	.swf-sign--prev.swf-sign--link:focus-within .swf-prev-name {
		text-decoration: underline;
		text-decoration-thickness: 2px;
		text-underline-offset: 4px;
	}

	/* ---- the route line ---- */

	.swf-route {
		display: grid;
		margin: 32px 0 0;
		padding: 0;
		list-style: none;
	}

	.swf-stop {
		position: relative;
		min-width: 0;
	}

	/* The line from this stop's dot to the next one's. The last stop is the end of the line. */
	.swf-stop:not(:last-child)::before {
		content: "";
		position: absolute;
		top: calc(10px + var(--swf-dot-c));
		bottom: calc(-10px - var(--swf-dot-c));
		left: calc(var(--swf-dot-c) - var(--swf-rail-w) / 2);
		width: var(--swf-rail-w);
		background: var(--swf-line);
	}

	/* The leg from here to the next stop is drawn in the sign colour. */
	.swf-stop--leg::before {
		background: var(--swf-sign) !important;
	}

	.swf-stop-link {
		position: relative;
		display: grid;
		grid-template-columns: calc(var(--swf-dot-c) * 2) auto minmax(0, 1fr);
		grid-template-areas:
			"dot num name"
			". . here";
		align-items: start;
		column-gap: 12px;
		min-height: 44px;
		padding: 10px 0;
		color: var(--swf-ink);
		text-decoration: none;
		border-radius: 4px;
	}

	.swf-dot {
		grid-area: dot;
		position: relative;
		z-index: 1;
		justify-self: center;
		align-self: start;
		width: 14px;
		height: 14px;
		margin-top: calc(var(--swf-dot-c) - 7px);
		border: 3px solid var(--swf-ink);
		border-radius: 50%;
		background: var(--swf-surface);
		transition: background-color 0.2s ease;
	}

	.swf-stop-num {
		grid-area: num;
		color: var(--swf-soft);
		font: 700 14px/24px var(--swf-font);
		font-variant-numeric: tabular-nums;
	}

	.swf-stop-name {
		grid-area: name;
		min-width: 0;
		font: 600 16px/24px var(--swf-font);
		overflow-wrap: break-word;
	}

	.swf-stop-here {
		grid-area: here;
		color: var(--swf-sign);
		font: 700 14px/1.3 var(--swf-font);
	}

	.swf-stop--here .swf-dot {
		width: 24px;
		height: 24px;
		margin-top: 0;
		border: 0;
		background: var(--swf-sign);
		box-shadow: 0 0 0 4px var(--swf-surface);
	}

	.swf-stop--here .swf-stop-name {
		font-weight: 800;
	}

	.swf-stop--next .swf-dot {
		width: 18px;
		height: 18px;
		margin-top: calc(var(--swf-dot-c) - 9px);
		border-color: var(--swf-sign);
	}

	.swf-stop--plain .swf-dot {
		border-color: var(--swf-line);
	}

	a.swf-stop-link:hover .swf-stop-name {
		text-decoration: underline;
		text-decoration-thickness: 2px;
		text-underline-offset: 4px;
	}

	a.swf-stop-link:hover .swf-dot {
		background: var(--swf-ink);
	}

	a.swf-stop-link:focus-visible {
		outline: 3px solid var(--swf-ring);
		outline-offset: 4px;
	}

	/* ---- tablet: signs side by side ---- */

	@container (min-width: 700px) {
		.swf-inner {
			padding: 72px 32px 80px;
		}

		.swf-head {
			margin-bottom: 24px;
		}

		.swf-head-aside {
			margin-left: auto;
		}

		.swf-heading,
		.swf-count {
			font-size: 21px;
		}

		.swf-pair {
			grid-template-columns: minmax(0, 1fr) minmax(0, 2.1fr);
			gap: 16px;
		}

		.swf-pair--solo {
			grid-template-columns: minmax(0, 1fr);
		}

		.swf-sign--prev {
			display: flex;
			flex-direction: column;
			justify-content: space-between;
			align-items: flex-start;
			gap: 32px;
			padding: 24px;
		}

		.swf-pair--solo .swf-sign--prev {
			flex-direction: row;
			align-items: center;
			justify-content: flex-start;
			gap: 20px;
		}

		.swf-arrow--back {
			width: 36px;
			height: 36px;
		}

		.swf-prev-name {
			font-size: 24px;
		}

		.swf-sign--next {
			grid-template-areas:
				"link num"
				"desc arrow";
			min-height: 232px;
			padding: 28px 28px 26px;
			gap: 18px 24px;
		}

		.swf-sign--next:not(:has(.swf-next-desc)) {
			grid-template-areas:
				"link num"
				"link arrow";
		}

		.swf-sign-num {
			position: static;
		}

		.swf-sign--next .swf-kicker {
			padding-right: 0;
		}

		.swf-next-name {
			font-size: max(30px, calc(clamp(44px, 6.4cqi, 60px) * var(--swf-fit)));
		}

		.swf-next-desc {
			font-size: 18px;
		}

		.swf-arrow--next {
			width: 64px;
			height: 64px;
		}

		.swf-kicker {
			font-size: 16px;
		}

		.swf-sign--prev .swf-kicker {
			font-size: 15px;
		}

		.swf-sign-num {
			font-size: 20px;
		}

		.swf-route {
			margin-top: 44px;
		}
	}

	/* ---- the route runs across when there is room for its stops ---- */

	@container (min-width: 700px) {
		.swf--route-s .swf-route {
			grid-template-columns: repeat(var(--swf-legs), minmax(0, 1fr)) fit-content(13rem);
		}

		.swf--route-s .swf-stop:not(:last-child)::before {
			top: calc(var(--swf-dot-c) - var(--swf-rail-w) / 2);
			bottom: auto;
			left: var(--swf-dot-c);
			right: calc(-1 * var(--swf-dot-c));
			width: auto;
			height: var(--swf-rail-w);
		}

		.swf--route-s .swf-stop-link {
			grid-template-columns: minmax(0, 1fr);
			grid-template-areas: "dot" "num" "name" "here";
			row-gap: 4px;
			/* Room between stops, so the focus ring never reaches the next dot. */
			margin-right: 22px;
			padding: 0 0 8px;
		}

		.swf--route-s .swf-dot {
			justify-self: start;
			margin-left: calc(var(--swf-dot-c) - 7px);
			margin-top: calc(var(--swf-dot-c) - 7px);
			margin-bottom: 12px;
		}

		.swf--route-s .swf-stop--here .swf-dot {
			margin: 0 0 7px;
		}

		.swf--route-s .swf-stop--next .swf-dot {
			margin: calc(var(--swf-dot-c) - 9px) 0 10px calc(var(--swf-dot-c) - 9px);
		}

		.swf--route-s .swf-stop-num,
		.swf--route-s .swf-stop-name {
			line-height: 1.35;
		}
	}

	@container (min-width: 960px) {
		.swf--route-m .swf-route {
			grid-template-columns: repeat(var(--swf-legs), minmax(0, 1fr)) fit-content(13rem);
		}

		.swf--route-m .swf-stop:not(:last-child)::before {
			top: calc(var(--swf-dot-c) - var(--swf-rail-w) / 2);
			bottom: auto;
			left: var(--swf-dot-c);
			right: calc(-1 * var(--swf-dot-c));
			width: auto;
			height: var(--swf-rail-w);
		}

		.swf--route-m .swf-stop-link {
			grid-template-columns: minmax(0, 1fr);
			grid-template-areas: "dot" "num" "name" "here";
			row-gap: 4px;
			/* Room between stops, so the focus ring never reaches the next dot. */
			margin-right: 20px;
			padding: 0 0 8px;
		}

		.swf--route-m .swf-dot {
			justify-self: start;
			margin-left: calc(var(--swf-dot-c) - 7px);
			margin-top: calc(var(--swf-dot-c) - 7px);
			margin-bottom: 12px;
		}

		.swf--route-m .swf-stop--here .swf-dot {
			margin: 0 0 7px;
		}

		.swf--route-m .swf-stop--next .swf-dot {
			margin: calc(var(--swf-dot-c) - 9px) 0 10px calc(var(--swf-dot-c) - 9px);
		}

		.swf--route-m .swf-stop-num,
		.swf--route-m .swf-stop-name {
			line-height: 1.35;
		}
	}

	@container (min-width: 1100px) {
		.swf--route-l .swf-route {
			grid-template-columns: repeat(var(--swf-legs), minmax(0, 1fr)) fit-content(13rem);
		}

		.swf--route-l .swf-stop:not(:last-child)::before {
			top: calc(var(--swf-dot-c) - var(--swf-rail-w) / 2);
			bottom: auto;
			left: var(--swf-dot-c);
			right: calc(-1 * var(--swf-dot-c));
			width: auto;
			height: var(--swf-rail-w);
		}

		.swf--route-l .swf-stop-link {
			grid-template-columns: minmax(0, 1fr);
			grid-template-areas: "dot" "num" "name" "here";
			row-gap: 4px;
			/* Room between stops, so the focus ring never reaches the next dot. */
			margin-right: 16px;
			padding: 0 0 8px;
		}

		.swf--route-l .swf-dot {
			justify-self: start;
			margin-left: calc(var(--swf-dot-c) - 7px);
			margin-top: calc(var(--swf-dot-c) - 7px);
			margin-bottom: 12px;
		}

		.swf--route-l .swf-stop--here .swf-dot {
			margin: 0 0 7px;
		}

		.swf--route-l .swf-stop--next .swf-dot {
			margin: calc(var(--swf-dot-c) - 9px) 0 10px calc(var(--swf-dot-c) - 9px);
		}

		.swf--route-l .swf-stop-name {
			font-size: 15px;
		}

		.swf--route-l .swf-stop-num,
		.swf--route-l .swf-stop-name {
			line-height: 1.35;
		}
	}

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

	@container (min-width: 960px) {
		.swf-inner {
			padding: 96px var(--swf-grid-gutter, 48px) 104px;
		}

		.swf-head {
			margin-bottom: 28px;
		}

		.swf-heading,
		.swf-count {
			font-size: 24px;
		}

		.swf-pair {
			gap: 20px;
		}

		.swf-sign--prev {
			padding: 32px;
		}

		.swf-arrow--back {
			width: 52px;
			height: 52px;
		}

		.swf-prev-name {
			font-size: 30px;
			line-height: 1.12;
		}

		.swf-sign--next {
			min-height: 272px;
			padding: 36px 44px 36px 44px;
			gap: 22px 40px;
		}

		.swf-next-name {
			font-size: max(34px, calc(clamp(56px, 6.2cqi, 84px) * var(--swf-fit)));
			line-height: 0.98;
			letter-spacing: -0.035em;
		}

		.swf-next-desc {
			font-size: 20px;
		}

		.swf-arrow--next {
			width: 96px;
			height: 96px;
		}

		.swf-kicker {
			font-size: 17px;
		}

		.swf-sign--prev .swf-kicker {
			font-size: 16px;
		}

		.swf-sign-num {
			font-size: 24px;
		}

		.swf-route {
			margin-top: 56px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.swf-arrow,
		.swf-dot,
		.swf-next-name {
			transition: none;
		}

		.swf-sign--link:hover .swf-arrow--next,
		.swf-sign--link:focus-within .swf-arrow--next {
			transform: none;
		}

		.swf-sign--link:hover .swf-arrow--back,
		.swf-sign--link:focus-within .swf-arrow--back {
			transform: scaleX(-1);
		}
	}

	@media (forced-colors: active) {
		.swf-sign {
			border: 2px solid CanvasText;
		}

		.swf-stop:not(:last-child)::before,
		.swf-stop--here .swf-dot {
			background: CanvasText;
		}

		.swf-sign-link:focus-visible,
		.swf-sign:has(.swf-sign-link:focus-visible),
		a.swf-stop-link:focus-visible,
		.swf-hub:focus-visible {
			outline-color: Highlight;
		}
	}
</style>

===== END src/components/ServiceWayfinder.astro =====

===== FILE (supporting): src/components/ServiceWayfinder.md =====
# Service wayfinder

Onward navigation for the foot of a service page: a filled 'next' sign with the next service set large and a solid arrow, a quieter 'previous' sign, and a route line with every service as a stop and this page marked 'You are here'. Takes the business's whole service list; renders nothing without two services and a known current page.

## Integration

- Place once, at the foot of each service page, with the same ordered service list on every page; pass `current` (1-based) or let it match a root-relative service href against the page URL. No match, no section.
- The root is a <nav> named by the heading, or by navLabel ("Services") without one. This page's stop is aria-current="page" and not a link.
- `wrap` (default true) makes the first service the next one from the last page. With wrap off, the last page shows indexLink as its next sign when one is passed; otherwise the previous sign takes the width.
- Layout follows the component's own width (container queries): signs side by side from 700px; the route runs across from 700px with up to six stops, from 960px with seven or eight, from 1100px with nine or more, and is a vertical line below that.
- Reads --swf-bg/-panel/-text/-text-muted/-brand/-brand-text/-border/-rail/-focus/-family and the grid hooks --swf-grid-max, --swf-grid-gutter. The sign colour (--swf-brand) must keep 4.5:1 with --swf-brand-text.
- Loads Overpass from Google Fonts by default. Pass fontStylesheet="" on a site that loads its own fonts.

## Provenance

Staged as candidate `service-wayfinder` (Step 28) and admitted through the candidate pipeline. Source type: original. References (direction only): none. Quality history: quality/candidates/records/service-wayfinder.json.

===== END src/components/ServiceWayfinder.md =====

--- 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: not applicable — this component ships no script.

--- 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
“Service wayfinder” from my Astro Component Library:
https://astro.baysixmedia.com/components/service-wayfinder/

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
Static
Profile
core only
Installation files
1 required + 1 supporting
Verified
Never
Client navigation
Not applicable — ships no script
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 fixture

src/components/library/ServiceWayfinder.astro

one fileNo imports, no companion files, no packages. Copy ServiceWayfinder.astro into any Astro project's components folder and render it.

---
/**
 * ServiceWayfinder (candidate)
 *
 * Onward navigation at the foot of a service page. It is not a list of what
 * the business does: it tells the visitor where they are among the business's
 * service pages and where to go next, the way a transit sign does.
 *
 *   - A header line: an optional heading, and the position as "03 / 06".
 *   - Two signs: a narrow "previous" sign and a wide, filled "next" sign —
 *     the next service's name set large, one line about it, a solid arrow.
 *   - A route line under them: every service as a stop on one drawn line,
 *     this page marked "you are here", the leg to the next stop drawn in the
 *     sign colour. Each other stop links to its page.
 *
 * Truth rules
 * - Everything shown comes from the supplied list. The position and count are
 *   derived from it; no service, line or link is invented.
 * - Fewer than two services, or no way to tell which one this page is, and
 *   nothing renders. `current` (1-based) says which; omitted, the component
 *   matches a service's root-relative href against the page URL.
 * - Links go through safeHref (http(s), mailto, tel, #, relative). A service
 *   without a usable href is a plain stop and, as a sign, a plain sign with no
 *   arrow — never a fake link.
 * - `wrap` (default true): from the last service "next" is the first. With
 *   wrap off, the last page's next sign is `indexLink` when one is passed,
 *   otherwise the previous sign takes the width.
 *
 * Responsive (container queries on the component's own width)
 * - < 700px: previous as a slim sign row, next as a full-width block, the
 *   route as a vertical line with the stops down it.
 * - ≥ 700px: the two signs side by side (1 : 2.1), the route horizontal when
 *   it holds six stops or fewer; seven to eight go horizontal from 960px,
 *   nine or more from 1100px.
 * - ≥ 960px: the next service's name up to 84px (stepped down for long
 *   names), a 96px arrow; content edge on the site grid.
 *
 * Interaction: no script. Hover or keyboard focus on a sign moves its arrow
 * along and underlines the name; the whole sign carries the focus ring.
 * Transform only; nothing moves under reduced motion.
 *
 * Hooks read (never declared): --swf-bg, --swf-panel, --swf-text,
 * --swf-text-muted, --swf-brand, --swf-brand-text, --swf-border, --swf-rail,
 * --swf-focus, --swf-family, --swf-grid-max, --swf-grid-gutter.
 */

export interface ServiceWayfinderStop {
	/** The service name, as supplied. */
	title: string;
	/** One line about the service; shown when it is the next stop. */
	description?: string;
	/** The service's own page. Without a usable one the stop is plain. */
	href?: string;
}

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

export interface ServiceWayfinderProps {
	/** Optional line over the signs, e.g. "More from …". Names the navigation when given. */
	heading?: string;
	headingLevel?: "h2" | "h3";
	/** Accessible name for the navigation when there is no heading. */
	navLabel?: string;
	/** Every service, in the order the site presents them. */
	services?: readonly ServiceWayfinderStop[];
	/** This page's service, 1-based. Omitted: matched by href against the page URL. */
	current?: number;
	/** From the last service, "next" is the first. */
	wrap?: boolean;
	/** The services hub. Shown beside the position; replaces the next sign at the end of an unwrapped route. */
	indexLink?: ServiceWayfinderLink;
	nextLabel?: string;
	previousLabel?: string;
	hereLabel?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends ServiceWayfinderProps {}

const {
	heading = "",
	headingLevel = "h2",
	navLabel = "Services",
	services = [],
	current,
	wrap = true,
	indexLink,
	nextLabel = "Next service",
	previousLabel = "Previous service",
	hereLabel = "You are here",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Overpass:wght@400;500;600;700;800&display=swap",
	class: className,
	id,
} = Astro.props as Props;

const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
/* An unsafe or empty link is dropped, never replaced with "#". */
const safeHref = (href: string | undefined) => (href && SAFE.test(href.trim()) ? href.trim() : "");
const pad = (n: number) => String(n).padStart(2, "0");

const stops = (services ?? [])
	.filter((s) => s && typeof s.title === "string" && s.title.trim())
	.map((s, i) => ({
		title: s.title.trim(),
		description: s.description?.trim() ?? "",
		href: safeHref(s.href),
		num: pad(i + 1),
	}));

/* Which stop is this page. */
const path = (p: string) => p.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
let here = -1;
if (typeof current === "number") {
	if (Number.isInteger(current) && current >= 1 && current <= stops.length) here = current - 1;
} else {
	const page = path(Astro.url?.pathname ?? "");
	here = stops.findIndex((s) => s.href.startsWith("/") && !s.href.startsWith("//") && path(s.href) === page);
}

const show = stops.length >= 2 && here >= 0;

let nextIdx = here + 1 < stops.length ? here + 1 : wrap ? 0 : -1;
let prevIdx = here - 1 >= 0 ? here - 1 : wrap ? stops.length - 1 : -1;
/* Two services: the one other service is next, never both next and previous. */
if (prevIdx === nextIdx) prevIdx = -1;

const hub = indexLink && indexLink.label?.trim() && safeHref(indexLink.href) ? { label: indexLink.label.trim(), href: safeHref(indexLink.href) } : null;

const nextStop = show && nextIdx >= 0 ? stops[nextIdx] : null;
const prevStop = show && prevIdx >= 0 ? stops[prevIdx] : null;
/* At the end of an unwrapped route the hub takes the next sign's place. */
const hubSign = show && !nextStop && hub ? hub : null;
const headHub = hub && !hubSign ? hub : null;

const nextName = nextStop?.title ?? hubSign?.label ?? "";
/* Long names step down so they stay a display line or two, not a wall of type. */
const fit = nextName.length > 28 ? "longer" : nextName.length > 18 ? "long" : "";
/* The drawn leg from here to the next stop, when the next stop is the adjacent one. */
const leg = nextIdx === here + 1;
const routeSize = stops.length <= 6 ? "s" : stops.length <= 8 ? "m" : "l";
const solo = !(prevStop && (nextStop || hubSign));
const Heading = headingLevel;
const label = heading.trim();
---

{show && (
<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<nav
	class:list={["swf", `swf--route-${routeSize}`, className]}
	id={id}
	aria-label={label || navLabel}
	style={`--swf-legs: ${stops.length - 1}`}
>
	<div class="swf-inner">
		<div class="swf-head">
			{label && <Heading class="swf-heading">{label}</Heading>}
			<div class="swf-head-aside">
				<p class="swf-count">
					<span class="swf-count-here">{stops[here].num}</span>
					<span class="swf-count-sep"> / </span>
					<span>{pad(stops.length)}</span>
				</p>
				{headHub && (
					<a class="swf-hub" href={headHub.href}>
						<span>{headHub.label}</span>
						<svg class="swf-hub-arrow" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><path d="M4 20h26V9l16 15-16 15V28H4z" /></svg>
					</a>
				)}
			</div>
		</div>

		<div class:list={["swf-pair", { "swf-pair--solo": solo }]}>
			{prevStop && (
				<div class:list={["swf-sign", "swf-sign--prev", { "swf-sign--link": prevStop.href }]}>
					{prevStop.href && (
						<svg class="swf-arrow swf-arrow--back" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><path d="M4 20h26V9l16 15-16 15V28H4z" /></svg>
					)}
					{prevStop.href ? (
						<a class="swf-sign-link" href={prevStop.href}>
							<span class="swf-kicker">{previousLabel}</span>{" "}
							<span class="swf-prev-name">{prevStop.title}</span>
						</a>
					) : (
						<p class="swf-sign-link">
							<span class="swf-kicker">{previousLabel}</span>{" "}
							<span class="swf-prev-name">{prevStop.title}</span>
						</p>
					)}
				</div>
			)}

			{(nextStop || hubSign) && (
				<div class:list={["swf-sign", "swf-sign--next", { "swf-sign--link": nextStop ? nextStop.href : true, [`swf-sign--${fit}`]: fit }]}>
					{nextStop && <span class="swf-sign-num" aria-hidden="true">{nextStop.num}</span>}
					{nextStop ? (
						nextStop.href ? (
							<a class="swf-sign-link" href={nextStop.href}>
								<span class="swf-kicker">{nextLabel}</span>{" "}
								<span class="swf-next-name">{nextStop.title}</span>
							</a>
						) : (
							<p class="swf-sign-link">
								<span class="swf-kicker">{nextLabel}</span>{" "}
								<span class="swf-next-name">{nextStop.title}</span>
							</p>
						)
					) : (
						<a class="swf-sign-link" href={hubSign!.href}>
							<span class="swf-next-name">{hubSign!.label}</span>
						</a>
					)}
					{nextStop?.description && <p class="swf-next-desc">{nextStop.description}</p>}
					{(hubSign || nextStop?.href) && (
						<svg class="swf-arrow swf-arrow--next" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><path d="M4 20h26V9l16 15-16 15V28H4z" /></svg>
					)}
				</div>
			)}
		</div>

		<ol class="swf-route" role="list">
			{stops.map((s, i) => {
				const isHere = i === here;
				/* This page is marked, not linked; a stop without a usable href is plain text. */
				const Tag = isHere || !s.href ? "span" : "a";
				return (
					<li class:list={["swf-stop", { "swf-stop--here": isHere, "swf-stop--next": i === nextIdx && !isHere, "swf-stop--leg": isHere && leg, "swf-stop--plain": !isHere && !s.href }]}>
						<Tag class="swf-stop-link" href={Tag === "a" ? s.href : undefined} aria-current={isHere ? "page" : undefined}>
							<span class="swf-dot" aria-hidden="true"></span>
							<span class="swf-stop-num">{s.num}</span>{" "}
							<span class="swf-stop-name">{s.title}</span>{" "}
							{isHere && <span class="swf-stop-here">{hereLabel}</span>}
						</Tag>
					</li>
				);
			})}
		</ol>
	</div>
</nav>
</>
)}

<style>
	.swf {
		--swf-surface: var(--swf-bg, #13212b);
		--swf-raised: var(--swf-panel, #1c2d39);
		--swf-ink: var(--swf-text, #f3f5f2);
		--swf-soft: var(--swf-text-muted, #bcc7cf);
		--swf-sign: var(--swf-brand, #f4cf3b);
		--swf-sign-ink: var(--swf-brand-text, #13212b);
		--swf-edge: var(--swf-border, #6d808e);
		--swf-line: var(--swf-rail, #6d808e);
		--swf-ring: var(--swf-focus, #ffffff);
		--swf-font: var(--swf-family, "Overpass", ui-sans-serif, system-ui, sans-serif);

		/* The route's dot centre, shared by the dot and the drawn line. */
		--swf-dot-c: 12px;
		--swf-rail-w: 3px;

		container-type: inline-size;
		display: block;
		background: var(--swf-surface);
		color: var(--swf-ink);
		font-family: var(--swf-font);
	}

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

	.swf-inner {
		max-width: var(--swf-grid-max, 1232px);
		margin: 0 auto;
		padding: 48px 20px 56px;
	}

	/* ---- header line: heading and position ---- */

	.swf-head {
		display: flex;
		flex-wrap: wrap;
		align-items: baseline;
		justify-content: space-between;
		gap: 8px 24px;
		margin-bottom: 20px;
	}

	.swf-heading {
		flex: 1 1 16rem;
		min-width: 0;
		margin: 0;
		color: var(--swf-ink);
		font: 700 19px/1.3 var(--swf-font);
		letter-spacing: -0.005em;
		overflow-wrap: break-word;
		text-wrap: balance;
	}

	.swf-head-aside {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		gap: 4px 20px;
	}

	.swf-count {
		margin: 0;
		color: var(--swf-soft);
		font: 700 19px/1 var(--swf-font);
		font-variant-numeric: tabular-nums;
		white-space: nowrap;
	}

	.swf-count-here {
		color: var(--swf-sign);
	}

	.swf-hub {
		display: inline-flex;
		align-items: center;
		gap: 8px;
		min-height: 44px;
		color: var(--swf-ink);
		font: 600 16px/1.2 var(--swf-font);
		text-decoration: underline;
		text-decoration-thickness: 2px;
		text-underline-offset: 5px;
		text-decoration-color: var(--swf-edge);
		border-radius: 4px;
	}

	.swf-hub:hover {
		text-decoration-color: var(--swf-sign);
	}

	.swf-hub:focus-visible {
		outline: 3px solid var(--swf-ring);
		outline-offset: 3px;
	}

	.swf-hub-arrow {
		flex: none;
		width: 16px;
		height: 16px;
		fill: currentColor;
	}

	/* ---- the signs ---- */

	.swf-pair {
		display: grid;
		gap: 12px;
	}

	.swf-sign {
		position: relative;
		min-width: 0;
		border-radius: 6px;
	}

	.swf-sign-link {
		display: grid;
		gap: 6px;
		min-width: 0;
		margin: 0;
		color: inherit;
		text-decoration: none;
	}

	/* The link's box covers its sign: one target, and the accessible name is kicker + service. */
	a.swf-sign-link::after {
		content: "";
		position: absolute;
		inset: 0;
		z-index: 1;
		border-radius: inherit;
	}

	.swf-sign--link {
		cursor: pointer;
	}

	/* The ring sits on the focused link itself, round the kicker and the name. */
	.swf-sign-link:focus-visible {
		outline: 3px solid var(--swf-ring);
		outline-offset: 6px;
		border-radius: 4px;
	}

	.swf-kicker {
		font: 700 15px/1.3 var(--swf-font);
		letter-spacing: 0.005em;
	}

	.swf-arrow {
		flex: none;
		fill: currentColor;
		transition: transform 0.3s cubic-bezier(0.22, 0.61, 0.36, 1);
	}

	/* previous: a slim row on phones */

	.swf-sign--prev {
		display: grid;
		grid-template-columns: auto minmax(0, 1fr);
		align-items: center;
		gap: 14px;
		min-height: 64px;
		padding: 12px 16px;
		background: var(--swf-raised);
		border: 2px solid var(--swf-edge);
		color: var(--swf-ink);
	}

	.swf-sign--prev:not(:has(.swf-arrow)) {
		grid-template-columns: minmax(0, 1fr);
	}

	.swf-sign--prev .swf-kicker {
		color: var(--swf-soft);
		font-size: 14px;
	}

	.swf-arrow--back {
		width: 28px;
		height: 28px;
		transform: scaleX(-1);
	}

	.swf-prev-name {
		font: 700 18px/1.2 var(--swf-font);
		letter-spacing: -0.01em;
		overflow-wrap: break-word;
		text-wrap: balance;
	}

	/* next: the filled sign */

	.swf-sign--next {
		--swf-fit: 1;

		display: grid;
		grid-template-columns: minmax(0, 1fr) auto;
		grid-template-areas:
			"link link"
			"desc arrow";
		align-items: end;
		gap: 14px 16px;
		padding: 22px 20px 20px;
		background: var(--swf-sign);
		color: var(--swf-sign-ink);
	}

	.swf-sign--long {
		--swf-fit: 0.8;
	}

	.swf-sign--longer {
		--swf-fit: 0.64;
	}

	.swf-sign--next .swf-sign-link {
		grid-area: link;
		align-self: start;
		gap: 10px;
	}

	.swf-sign--next .swf-kicker {
		padding-right: 44px;
	}

	/* Phones: pinned in the corner so the name keeps the sign's full width. */
	.swf-sign-num {
		position: absolute;
		top: 22px;
		right: 20px;
		grid-area: num;
		align-self: start;
		font: 800 18px/1 var(--swf-font);
		font-variant-numeric: tabular-nums;
	}

	.swf-next-name {
		font-weight: 800;
		font-size: max(26px, calc(clamp(34px, 9.5cqi, 44px) * var(--swf-fit)));
		line-height: 1;
		letter-spacing: -0.03em;
		overflow-wrap: break-word;
		text-wrap: balance;
		text-decoration-line: underline;
		text-decoration-thickness: 0.07em;
		text-underline-offset: 0.12em;
		text-decoration-color: transparent;
		transition: text-decoration-color 0.2s ease;
	}

	.swf-next-desc {
		grid-area: desc;
		align-self: start;
		max-width: 46ch;
		margin: 0;
		font: 500 17px/1.45 var(--swf-font);
		text-wrap: pretty;
		overflow-wrap: break-word;
	}

	.swf-arrow--next {
		grid-area: arrow;
		width: 44px;
		height: 44px;
	}

	/* No line under the name: the arrow sits under it, at the end. */
	.swf-sign--next:not(:has(.swf-next-desc)) {
		grid-template-areas:
			"link link"
			". arrow";
	}

	/* hover and focus: the arrow moves on, the name is underlined */

	.swf-sign--link:hover .swf-arrow--next,
	.swf-sign--link:focus-within .swf-arrow--next {
		transform: translateX(8px);
	}

	.swf-sign--link:hover .swf-arrow--back,
	.swf-sign--link:focus-within .swf-arrow--back {
		transform: scaleX(-1) translateX(6px);
	}

	.swf-sign--link:hover .swf-next-name,
	.swf-sign--link:focus-within .swf-next-name {
		text-decoration-color: currentColor;
	}

	.swf-sign--prev.swf-sign--link:hover {
		border-color: var(--swf-sign);
	}

	.swf-sign--prev.swf-sign--link:hover .swf-prev-name,
	.swf-sign--prev.swf-sign--link:focus-within .swf-prev-name {
		text-decoration: underline;
		text-decoration-thickness: 2px;
		text-underline-offset: 4px;
	}

	/* ---- the route line ---- */

	.swf-route {
		display: grid;
		margin: 32px 0 0;
		padding: 0;
		list-style: none;
	}

	.swf-stop {
		position: relative;
		min-width: 0;
	}

	/* The line from this stop's dot to the next one's. The last stop is the end of the line. */
	.swf-stop:not(:last-child)::before {
		content: "";
		position: absolute;
		top: calc(10px + var(--swf-dot-c));
		bottom: calc(-10px - var(--swf-dot-c));
		left: calc(var(--swf-dot-c) - var(--swf-rail-w) / 2);
		width: var(--swf-rail-w);
		background: var(--swf-line);
	}

	/* The leg from here to the next stop is drawn in the sign colour. */
	.swf-stop--leg::before {
		background: var(--swf-sign) !important;
	}

	.swf-stop-link {
		position: relative;
		display: grid;
		grid-template-columns: calc(var(--swf-dot-c) * 2) auto minmax(0, 1fr);
		grid-template-areas:
			"dot num name"
			". . here";
		align-items: start;
		column-gap: 12px;
		min-height: 44px;
		padding: 10px 0;
		color: var(--swf-ink);
		text-decoration: none;
		border-radius: 4px;
	}

	.swf-dot {
		grid-area: dot;
		position: relative;
		z-index: 1;
		justify-self: center;
		align-self: start;
		width: 14px;
		height: 14px;
		margin-top: calc(var(--swf-dot-c) - 7px);
		border: 3px solid var(--swf-ink);
		border-radius: 50%;
		background: var(--swf-surface);
		transition: background-color 0.2s ease;
	}

	.swf-stop-num {
		grid-area: num;
		color: var(--swf-soft);
		font: 700 14px/24px var(--swf-font);
		font-variant-numeric: tabular-nums;
	}

	.swf-stop-name {
		grid-area: name;
		min-width: 0;
		font: 600 16px/24px var(--swf-font);
		overflow-wrap: break-word;
	}

	.swf-stop-here {
		grid-area: here;
		color: var(--swf-sign);
		font: 700 14px/1.3 var(--swf-font);
	}

	.swf-stop--here .swf-dot {
		width: 24px;
		height: 24px;
		margin-top: 0;
		border: 0;
		background: var(--swf-sign);
		box-shadow: 0 0 0 4px var(--swf-surface);
	}

	.swf-stop--here .swf-stop-name {
		font-weight: 800;
	}

	.swf-stop--next .swf-dot {
		width: 18px;
		height: 18px;
		margin-top: calc(var(--swf-dot-c) - 9px);
		border-color: var(--swf-sign);
	}

	.swf-stop--plain .swf-dot {
		border-color: var(--swf-line);
	}

	a.swf-stop-link:hover .swf-stop-name {
		text-decoration: underline;
		text-decoration-thickness: 2px;
		text-underline-offset: 4px;
	}

	a.swf-stop-link:hover .swf-dot {
		background: var(--swf-ink);
	}

	a.swf-stop-link:focus-visible {
		outline: 3px solid var(--swf-ring);
		outline-offset: 4px;
	}

	/* ---- tablet: signs side by side ---- */

	@container (min-width: 700px) {
		.swf-inner {
			padding: 72px 32px 80px;
		}

		.swf-head {
			margin-bottom: 24px;
		}

		.swf-head-aside {
			margin-left: auto;
		}

		.swf-heading,
		.swf-count {
			font-size: 21px;
		}

		.swf-pair {
			grid-template-columns: minmax(0, 1fr) minmax(0, 2.1fr);
			gap: 16px;
		}

		.swf-pair--solo {
			grid-template-columns: minmax(0, 1fr);
		}

		.swf-sign--prev {
			display: flex;
			flex-direction: column;
			justify-content: space-between;
			align-items: flex-start;
			gap: 32px;
			padding: 24px;
		}

		.swf-pair--solo .swf-sign--prev {
			flex-direction: row;
			align-items: center;
			justify-content: flex-start;
			gap: 20px;
		}

		.swf-arrow--back {
			width: 36px;
			height: 36px;
		}

		.swf-prev-name {
			font-size: 24px;
		}

		.swf-sign--next {
			grid-template-areas:
				"link num"
				"desc arrow";
			min-height: 232px;
			padding: 28px 28px 26px;
			gap: 18px 24px;
		}

		.swf-sign--next:not(:has(.swf-next-desc)) {
			grid-template-areas:
				"link num"
				"link arrow";
		}

		.swf-sign-num {
			position: static;
		}

		.swf-sign--next .swf-kicker {
			padding-right: 0;
		}

		.swf-next-name {
			font-size: max(30px, calc(clamp(44px, 6.4cqi, 60px) * var(--swf-fit)));
		}

		.swf-next-desc {
			font-size: 18px;
		}

		.swf-arrow--next {
			width: 64px;
			height: 64px;
		}

		.swf-kicker {
			font-size: 16px;
		}

		.swf-sign--prev .swf-kicker {
			font-size: 15px;
		}

		.swf-sign-num {
			font-size: 20px;
		}

		.swf-route {
			margin-top: 44px;
		}
	}

	/* ---- the route runs across when there is room for its stops ---- */

	@container (min-width: 700px) {
		.swf--route-s .swf-route {
			grid-template-columns: repeat(var(--swf-legs), minmax(0, 1fr)) fit-content(13rem);
		}

		.swf--route-s .swf-stop:not(:last-child)::before {
			top: calc(var(--swf-dot-c) - var(--swf-rail-w) / 2);
			bottom: auto;
			left: var(--swf-dot-c);
			right: calc(-1 * var(--swf-dot-c));
			width: auto;
			height: var(--swf-rail-w);
		}

		.swf--route-s .swf-stop-link {
			grid-template-columns: minmax(0, 1fr);
			grid-template-areas: "dot" "num" "name" "here";
			row-gap: 4px;
			/* Room between stops, so the focus ring never reaches the next dot. */
			margin-right: 22px;
			padding: 0 0 8px;
		}

		.swf--route-s .swf-dot {
			justify-self: start;
			margin-left: calc(var(--swf-dot-c) - 7px);
			margin-top: calc(var(--swf-dot-c) - 7px);
			margin-bottom: 12px;
		}

		.swf--route-s .swf-stop--here .swf-dot {
			margin: 0 0 7px;
		}

		.swf--route-s .swf-stop--next .swf-dot {
			margin: calc(var(--swf-dot-c) - 9px) 0 10px calc(var(--swf-dot-c) - 9px);
		}

		.swf--route-s .swf-stop-num,
		.swf--route-s .swf-stop-name {
			line-height: 1.35;
		}
	}

	@container (min-width: 960px) {
		.swf--route-m .swf-route {
			grid-template-columns: repeat(var(--swf-legs), minmax(0, 1fr)) fit-content(13rem);
		}

		.swf--route-m .swf-stop:not(:last-child)::before {
			top: calc(var(--swf-dot-c) - var(--swf-rail-w) / 2);
			bottom: auto;
			left: var(--swf-dot-c);
			right: calc(-1 * var(--swf-dot-c));
			width: auto;
			height: var(--swf-rail-w);
		}

		.swf--route-m .swf-stop-link {
			grid-template-columns: minmax(0, 1fr);
			grid-template-areas: "dot" "num" "name" "here";
			row-gap: 4px;
			/* Room between stops, so the focus ring never reaches the next dot. */
			margin-right: 20px;
			padding: 0 0 8px;
		}

		.swf--route-m .swf-dot {
			justify-self: start;
			margin-left: calc(var(--swf-dot-c) - 7px);
			margin-top: calc(var(--swf-dot-c) - 7px);
			margin-bottom: 12px;
		}

		.swf--route-m .swf-stop--here .swf-dot {
			margin: 0 0 7px;
		}

		.swf--route-m .swf-stop--next .swf-dot {
			margin: calc(var(--swf-dot-c) - 9px) 0 10px calc(var(--swf-dot-c) - 9px);
		}

		.swf--route-m .swf-stop-num,
		.swf--route-m .swf-stop-name {
			line-height: 1.35;
		}
	}

	@container (min-width: 1100px) {
		.swf--route-l .swf-route {
			grid-template-columns: repeat(var(--swf-legs), minmax(0, 1fr)) fit-content(13rem);
		}

		.swf--route-l .swf-stop:not(:last-child)::before {
			top: calc(var(--swf-dot-c) - var(--swf-rail-w) / 2);
			bottom: auto;
			left: var(--swf-dot-c);
			right: calc(-1 * var(--swf-dot-c));
			width: auto;
			height: var(--swf-rail-w);
		}

		.swf--route-l .swf-stop-link {
			grid-template-columns: minmax(0, 1fr);
			grid-template-areas: "dot" "num" "name" "here";
			row-gap: 4px;
			/* Room between stops, so the focus ring never reaches the next dot. */
			margin-right: 16px;
			padding: 0 0 8px;
		}

		.swf--route-l .swf-dot {
			justify-self: start;
			margin-left: calc(var(--swf-dot-c) - 7px);
			margin-top: calc(var(--swf-dot-c) - 7px);
			margin-bottom: 12px;
		}

		.swf--route-l .swf-stop--here .swf-dot {
			margin: 0 0 7px;
		}

		.swf--route-l .swf-stop--next .swf-dot {
			margin: calc(var(--swf-dot-c) - 9px) 0 10px calc(var(--swf-dot-c) - 9px);
		}

		.swf--route-l .swf-stop-name {
			font-size: 15px;
		}

		.swf--route-l .swf-stop-num,
		.swf--route-l .swf-stop-name {
			line-height: 1.35;
		}
	}

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

	@container (min-width: 960px) {
		.swf-inner {
			padding: 96px var(--swf-grid-gutter, 48px) 104px;
		}

		.swf-head {
			margin-bottom: 28px;
		}

		.swf-heading,
		.swf-count {
			font-size: 24px;
		}

		.swf-pair {
			gap: 20px;
		}

		.swf-sign--prev {
			padding: 32px;
		}

		.swf-arrow--back {
			width: 52px;
			height: 52px;
		}

		.swf-prev-name {
			font-size: 30px;
			line-height: 1.12;
		}

		.swf-sign--next {
			min-height: 272px;
			padding: 36px 44px 36px 44px;
			gap: 22px 40px;
		}

		.swf-next-name {
			font-size: max(34px, calc(clamp(56px, 6.2cqi, 84px) * var(--swf-fit)));
			line-height: 0.98;
			letter-spacing: -0.035em;
		}

		.swf-next-desc {
			font-size: 20px;
		}

		.swf-arrow--next {
			width: 96px;
			height: 96px;
		}

		.swf-kicker {
			font-size: 17px;
		}

		.swf-sign--prev .swf-kicker {
			font-size: 16px;
		}

		.swf-sign-num {
			font-size: 24px;
		}

		.swf-route {
			margin-top: 56px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.swf-arrow,
		.swf-dot,
		.swf-next-name {
			transition: none;
		}

		.swf-sign--link:hover .swf-arrow--next,
		.swf-sign--link:focus-within .swf-arrow--next {
			transform: none;
		}

		.swf-sign--link:hover .swf-arrow--back,
		.swf-sign--link:focus-within .swf-arrow--back {
			transform: scaleX(-1);
		}
	}

	@media (forced-colors: active) {
		.swf-sign {
			border: 2px solid CanvasText;
		}

		.swf-stop:not(:last-child)::before,
		.swf-stop--here .swf-dot {
			background: CanvasText;
		}

		.swf-sign-link:focus-visible,
		.swf-sign:has(.swf-sign-link:focus-visible),
		a.swf-stop-link:focus-visible,
		.swf-hub:focus-visible {
			outline-color: Highlight;
		}
	}
</style>