Component library
Back to catalog

Profile card

A square portrait in a rounded frame, a verified name and bio, and a footer of marks for what the person offers beside a view control that links out or reports the press.

cardprofileavatarlinkdark

Open preview in a new tab

Use this component

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

Start here

Placement prompt

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

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

Library page, for reference only:
https://astro.baysixmedia.com/components/profile-card/

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

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

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

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

Install kit

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

Read it first
Install kit: "Profile card"
Library page: https://astro.baysixmedia.com/components/profile-card/
Files: 3 required, 1 supporting

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

===== FILE (required): src/components/ProfileCard.astro =====
---
/**
 * Profile card
 *
 * A square portrait in a rounded frame, a name with an optional verified
 * badge, a short bio, and a footer row of counts beside a view control.
 *
 * The control is a single action. Given an `href` it renders as a link and
 * the component ships no behaviour at all; without one it is a button that
 * dispatches an event the host can listen for.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont.
 * Every colour, size and duration is a `--pcd-*` custom property on the root.
 */

export interface ProfileCardStat {
	/**
	 * The figure, already formatted — "1,219", "2.4k", "172". Leave it out
	 * and the item is the icon alone, which is what the default card does:
	 * three marks saying what this person offers, not how many of it.
	 */
	value?: string;
	/**
	 * What it is. Not shown; it is the accessible name for the item, and it
	 * is the ONLY thing read when there is no figure beside it, so it has to
	 * say something on its own.
	 */
	label: string;
	/** Which glyph sits before it, or instead of it. */
	icon?:
		| "cross"
		| "phone"
		| "website"
		| "followers"
		| "photos"
		| "posts"
		| "none";
}

interface Props {
	/** Who the card is for. */
	name?: string;
	/** One or two lines under the name. `""` hides it. */
	bio?: string;
	/** Portrait. Square; anything else is cropped to the centre. */
	image?: string;
	/**
	 * Alternative text. Left empty the portrait is marked decorative, which
	 * is right when the name beside it already identifies the person.
	 */
	imageAlt?: string;
	/** Show the check beside the name. */
	verified?: boolean;
	/** Accessible name for that check. */
	verifiedLabel?: string;
	/** Up to three items in the footer. A fourth is dropped, not wrapped. */
	stats?: ProfileCardStat[];
	/** Footer control text. `""` drops the control entirely. */
	actionLabel?: string;
	/**
	 * Render the control as a link to this URL, which is the only form that
	 * works without JavaScript. Without one the control is a button that
	 * announces itself with a `profile-card:view` event and leaves the rest
	 * to the host.
	 */
	href?: string;
	/** Heading level for the name. */
	headingLevel?: "h2" | "h3" | "h4" | "p";
	/** The card's fill. */
	background?: string;
	/** Name, figures and control text. */
	ink?: string;
	/** The verified check. */
	accent?: string;
	/** Hairline around the portrait. */
	frameColor?: string;
	/** Card corner radius. The portrait uses the same. */
	radius?: string;
	/** Added to the root. */
	class?: string;
	/** Root element id. */
	id?: string;
}

const {
	name = "Ada Whitfield",
	bio = "Pre and post-operative breast care specialist.",
	image = "/profile-card/portrait-1.svg",
	imageAlt = "",
	verified = false,
	verifiedLabel = "Verified account",
	stats = [
		{ label: "Clinical care", icon: "cross" },
		{ label: "Phone", icon: "phone" },
		{ label: "Website", icon: "website" },
	],
	actionLabel = "View",
	href,
	headingLevel = "h2",
	background = "#000000",
	ink = "#ffffff",
	accent = "#10b981",
	frameColor = "rgb(0 0 0 / 0.1)",
	radius = "32px",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/*
 * Only schemes that navigate. The card is copied into other people's
 * projects where the href may come from a profile record rather than from
 * the person writing the template.
 */
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (value?: string) =>
	value && SAFE_SCHEME.test(value.trim()) ? value.trim() : undefined;

const link = safeHref(href);
/* An anchor when there is somewhere to go, a button when there is not. The
   two differ by four attributes, so they are one element rather than two
   branches holding copies of the same glyph. */
const Control = link ? "a" : "button";
const src = safeHref(image);

/* Three is what the footer row has room for beside the control once the
   items are bare icons; a fourth would push the button onto its own line
   rather than fitting. Measured at 320px: three 20px marks and their gaps
   come to 84px of the 280px the row has, leaving the control its 111. */
const figures = stats.slice(0, 3);

/* Icons are inlined rather than fetched, so the card makes no request of its
   own beyond the portrait. Drawn on a 24 grid at stroke 2, like the rest. */
const GLYPHS: Record<string, string[]> = {
	/* An outlined Greek cross rather than a bare plus: the plus is the
	   universal "add", and this has to read as medical at 20px. */
	cross: ["M9 3h6v6h6v6h-6v6H9v-6H3V9h6z"],
	phone: [
		"M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z",
		"M11 18h2",
	],
	/* The ring is drawn as two arcs rather than a <circle>, so every glyph in
	   this table is a path list and the renderer stays one loop. */
	website: [
		"M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20z",
		"M2 12h20",
		"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z",
	],
	followers: ["M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"],
	photos: [
		"M3 5.5A2.5 2.5 0 0 1 5.5 3h13A2.5 2.5 0 0 1 21 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 18.5z",
		"m4 16 4.6-4.6a2 2 0 0 1 2.8 0L16 16",
		"m14 14 1.6-1.6a2 2 0 0 1 2.8 0L20 14",
	],
	posts: [
		"M4 5.5A1.5 1.5 0 0 1 5.5 4h13A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18.5z",
		"M8 9h8M8 13h8M8 17h5",
	],
};

const style = [
	`--pcd-bg:${background}`,
	`--pcd-ink:${ink}`,
	`--pcd-accent:${accent}`,
	`--pcd-frame:${frameColor}`,
	`--pcd-radius:${radius}`,
].join(";");
---

<article
	class:list={["profile-card", className]}
	id={id}
	style={style}
	data-profile-card
>
	<div class="pcd-frame">
		{src ? (
			<img
				class="pcd-photo"
				src={src}
				alt={imageAlt}
				width="800"
				height="800"
				loading="lazy"
				decoding="async"
			/>
		) : (
			<div class="pcd-photo pcd-photo--empty" aria-hidden="true"></div>
		)}
	</div>

	<div class="pcd-body">
		<div class="pcd-nameline">
			<Heading class="pcd-name">{name}</Heading>
			{verified && (
				<svg
					class="pcd-check"
					viewBox="0 0 24 24"
					fill="none"
					stroke="currentColor"
					stroke-width="2"
					stroke-linecap="round"
					stroke-linejoin="round"
					role="img"
					aria-label={verifiedLabel}
				>
					<path
						d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"
					></path>
					<path d="m9 12 2 2 4-4"></path>
				</svg>
			)}
		</div>

		{bio && <p class="pcd-bio">{bio}</p>}

		<div class="pcd-footer">
			{figures.map((stat) => (
				<p class="pcd-stat">
					{stat.icon !== "none" && GLYPHS[stat.icon ?? "followers"] && (
						<svg
							class="pcd-stat-icon"
							viewBox="0 0 24 24"
							fill="none"
							stroke="currentColor"
							stroke-width="2"
							stroke-linecap="round"
							stroke-linejoin="round"
							aria-hidden="true"
						>
							{GLYPHS[stat.icon ?? "followers"].map((d) => <path d={d}></path>)}
							{(stat.icon ?? "followers") === "followers" && (
								<circle cx="12" cy="7" r="4"></circle>
							)}
						</svg>
					)}
					{stat.value && <span class="pcd-stat-value">{stat.value}</span>}
					{/* The figure on its own says "1,219" and nothing else, and an
					    icon on its own says less than that. The word is here rather
					    than in a title attribute so it is read, not hovered for. */}
					<span class="pcd-a11y">{stat.label}</span>
				</p>
			))}

			{actionLabel && (
				/*
				  One element either way.

				  With an `href` it is a link: served visible, works with no
				  JavaScript at all, and nothing in the controller below
				  matches it.

				  Without one it is a button, served `hidden` and revealed
				  once the controller can make it do something. The only thing
				  a button here can do is tell the host it was pressed, and
				  that needs script — so a scripting-disabled page gets no
				  control rather than a dead one. The reference ships a button
				  that takes focus, says its word and does nothing.
				*/
				<Control
					class="pcd-action"
					href={link}
					type={link ? undefined : "button"}
					hidden={link ? undefined : true}
					data-pcd-view={link ? undefined : ""}
				>
					{actionLabel}
					{/* Points the way out of the card. One glyph, no states to
					    keep in step. */}
					<svg
						class="pcd-action-icon"
						viewBox="0 0 24 24"
						fill="none"
						stroke="currentColor"
						stroke-width="2"
						stroke-linecap="round"
						stroke-linejoin="round"
						aria-hidden="true"
					>
						<path d="M5 12h14"></path>
						<path d="m12 5 7 7-7 7"></path>
					</svg>
				</Control>
			)}
		</div>
	</div>
</article>

<script>
	/*
	 * The view control, and only when it is a button.
	 *
	 * Given an `href` the control is a link, none of this matches it, and the
	 * card is markup and CSS with no behaviour at all. Without one the button
	 * has nothing it can do by itself, so it is served `hidden` and revealed
	 * here — a control that cannot act should not be offered. The reference
	 * ships one regardless: focusable, labelled, inert.
	 */
	interface Instance {
		destroy(): void;
	}

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

		const button = root.querySelector<HTMLButtonElement>("[data-pcd-view]");
		if (!button) return undefined;

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

		/* It works from here on, so it can be seen from here on. */
		button.hidden = false;

		button.addEventListener(
			"click",
			() => {
				/*
				 * The card does not know what viewing means in the host's
				 * application — a route, a drawer, a panel — so it says what
				 * happened and lets the host decide. Bubbles and composed, so
				 * a listener on the page catches it.
				 */
				root.dispatchEvent(
					new CustomEvent("profile-card:view", {
						detail: { name: root.querySelector(".pcd-name")?.textContent ?? "" },
						bubbles: true,
						composed: true,
					}),
				);
			},
			{ signal },
		);

		function destroy() {
			button!.hidden = true;
			delete root.dataset.pcdReady;
			controller.abort();
		}

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

		return { destroy };
	}

	const boot = () =>
		document.querySelectorAll<HTMLElement>("[data-profile-card]").forEach(initProfileCard);

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

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

<style>
	.profile-card {
		/*
		 * 320px is the width everything below was measured at. `width: 100%`
		 * under it is the fix for the reference, which is a fixed 320 and
		 * therefore runs off the side of a 280px screen.
		 */
		--pcd-width: 320px;
		--pcd-pad: 8px;
		/*
		 * The inset the name, bio and footer share. The reference uses 10/12
		 * of the frame, which is 25.3px here and leaves the footer 16px too
		 * narrow for its own contents — see the footer rule below.
		 */
		--pcd-inset: 12px;
		--pcd-gap: 12px;

		--pcd-name-size: 1.5rem;
		--pcd-bio-size: 1rem;
		--pcd-stat-size: 1rem;
		--pcd-icon: 20px;

		--pcd-dim: 0.6;
		--pcd-faint: 0.4;
		--pcd-fill: 0.1;
		--pcd-fill-hover: 0.2;

		--pcd-shadow: 0 1px 3px rgb(0 0 0 / 0.2), 0 1px 2px -1px rgb(0 0 0 / 0.2);
		--pcd-ease: cubic-bezier(0.4, 0, 0.2, 1);
		--pcd-duration: 150ms;

		box-sizing: border-box;
		display: block;
		inline-size: 100%;
		max-inline-size: var(--pcd-width);
		/*
		 * Centred in whatever it is given, like every other capped component
		 * in this library. Without it a block with a max-width sits at the
		 * start of its container, which reads as a mistake rather than as a
		 * choice: in a 390px frame at 24px padding the card took the left 320
		 * and left 22px of nothing on the right. No effect at all in a
		 * container narrower than the cap, where the card already fills it.
		 */
		margin-inline: auto;
		padding: var(--pcd-pad);
		border-radius: var(--pcd-radius);
		background: var(--pcd-bg);
		color: var(--pcd-ink);
		box-shadow: var(--pcd-shadow);
		/* A system stack on purpose: a component in this library fetches
		   nothing, so it can never be waiting on a webfont. */
		font-family:
			system-ui,
			-apple-system,
			"Segoe UI",
			Roboto,
			Helvetica,
			Arial,
			sans-serif;
	}

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

	/*
	 * A scoped reset, and it earns its place.
	 *
	 * Everything below sets type on classes, which beats inheritance — but it
	 * does not beat a host rule aimed at the bare element. A page carrying
	 * `h2 { padding: 0 40px }` silently gave the name 80px of padding and
	 * pushed the verified check to the far edge of the card; the font was
	 * right, the class rules were right, and the element was 80px too wide.
	 * Found by measuring the same card on two pages and getting two answers.
	 *
	 * `:where()` keeps this at the specificity of `.profile-card` alone, so
	 * the rules underneath still win on source order while a host's element
	 * selector loses.
	 *
	 * The `:global()` is load-bearing. Astro scopes every compound it can, and
	 * since it cannot put its attribute inside `:where()` it adds a second
	 * one beside it — `.profile-card[cid] [cid]:where(h2)` — which lands at
	 * three classes' worth of specificity and beats `.pcd-name[cid]` at two.
	 * The visible result was a 16px name and a button with no pill. Marking
	 * the inner half global leaves the scope on the ancestor alone, where it
	 * ties with the class rules and loses to them on source order.
	 */
	.profile-card :global(:where(h1, h2, h3, h4, h5, h6, p, span, a, button)) {
		margin: 0;
		padding: 0;
		border: 0;
		background: none;
		font: inherit;
		color: inherit;
		letter-spacing: inherit;
		text-align: inherit;
		text-transform: none;
		text-decoration: none;
	}

	.profile-card :global(:where(button)) {
		appearance: none;
	}

	/* ---- the portrait --------------------------------------------------- */

	.pcd-frame {
		aspect-ratio: 1 / 1;
		overflow: hidden;
		border: 2px solid var(--pcd-frame);
		border-radius: var(--pcd-radius);
	}

	.pcd-photo {
		display: block;
		inline-size: 100%;
		block-size: 100%;
		object-fit: cover;
		object-position: 50% 50%;
	}

	/* No portrait passed: a tinted panel rather than a broken image. */
	.pcd-photo--empty {
		background:
			linear-gradient(140deg, rgb(255 255 255 / 0.12), rgb(255 255 255 / 0.03));
	}

	/* ---- name, bio ------------------------------------------------------ */

	.pcd-body {
		margin-block-start: 24px;
		padding-inline: var(--pcd-inset);
	}

	.pcd-nameline {
		display: flex;
		align-items: center;
		gap: 8px;
	}

	.pcd-name {
		margin: 0;
		font-size: var(--pcd-name-size);
		font-weight: 600;
		line-height: 1.3333;
		/* -0.05em. The name is the one thing set tight enough to notice. */
		letter-spacing: -0.05em;
		color: var(--pcd-ink);
	}

	.pcd-check {
		flex: 0 0 auto;
		inline-size: var(--pcd-icon);
		block-size: var(--pcd-icon);
		color: var(--pcd-accent);
	}

	.pcd-bio {
		margin: 8px 0 0;
		font-size: var(--pcd-bio-size);
		line-height: 1.75;
		color: var(--pcd-ink);
		opacity: var(--pcd-dim);
	}

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

	.pcd-footer {
		display: flex;
		align-items: center;
		gap: var(--pcd-gap);
		/*
		 * Wrapping is the safety valve, not the layout. At the default size
		 * everything sits on one line; a five-figure count or a longer control
		 * label sends the control to its own line instead of out of the card,
		 * which is what the reference does at every width it is rendered at.
		 */
		flex-wrap: wrap;
		margin-block: 24px 0;
	}

	.pcd-stat {
		position: relative;
		display: flex;
		align-items: center;
		gap: 8px;
		margin: 0;
		font-size: var(--pcd-stat-size);
		font-weight: 600;
		line-height: 1.75;
		color: var(--pcd-ink);
	}

	.pcd-stat-icon {
		flex: 0 0 auto;
		inline-size: var(--pcd-icon);
		block-size: var(--pcd-icon);
		opacity: var(--pcd-faint);
	}

	/*
	 * An icon beside a figure is a label for it, and 40% of the ink is enough.
	 * An icon with no figure IS the item, and at 40% it reads as disabled — so
	 * a lone mark comes up to the same 60% the bio sits at. `:only-child`
	 * rather than a modifier class, because the markup already says it: the
	 * visually hidden label is absolutely positioned and out of flow, so an
	 * item with no figure has exactly one child in flow.
	 */
	.pcd-stat-icon:first-child:nth-last-child(2) {
		opacity: var(--pcd-dim);
	}

	.pcd-stat-value {
		font-variant-numeric: tabular-nums;
	}

	/* Read, not seen: the figure alone does not say what it counts. */
	.pcd-a11y {
		position: absolute;
		inline-size: 1px;
		block-size: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
		border: 0;
	}

	.pcd-action {
		/* Pushed to the end rather than centred with the figures, so the row
		   reads as "counts, then the thing you press". */
		margin-inline-start: auto;
		flex: 0 0 auto;
		display: inline-flex;
		align-items: center;
		justify-content: center;
		gap: 8px;
		/*
		 * The reference's 24px, restored. It was 16 for as long as the control
		 * had to hold "Following" as well as "Follow" — 147px of label in a
		 * row with 126px for it. A single short label removes that constraint,
		 * so the measured value goes back.
		 */
		padding: 12px 24px;
		border: 0;
		border-radius: 999px;
		background: color-mix(in srgb, var(--pcd-ink) calc(var(--pcd-fill) * 100%), transparent);
		color: var(--pcd-ink);
		font: inherit;
		font-size: 1rem;
		font-weight: 600;
		line-height: 1.5;
		text-decoration: none;
		cursor: pointer;
		transition: background-color var(--pcd-duration) var(--pcd-ease);
	}

	/* `display: inline-flex` above would otherwise beat the attribute, and the
	   control would be visible and dead before the controller runs. */
	.pcd-action[hidden] {
		display: none;
	}

	.pcd-action:hover {
		background: color-mix(in srgb, var(--pcd-ink) calc(var(--pcd-fill-hover) * 100%), transparent);
	}

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

	.pcd-action-icon {
		flex: 0 0 auto;
		inline-size: var(--pcd-icon);
		block-size: var(--pcd-icon);
		transition: translate var(--pcd-duration) var(--pcd-ease);
	}

	/*
	 * The arrow leans the way it is going. 2px, and the control keeps its own
	 * size — the glyph moves inside a box that does not, so nothing in the
	 * footer reflows and the card cannot change height on hover.
	 */
	.pcd-action:hover .pcd-action-icon,
	.pcd-action:focus-visible .pcd-action-icon {
		translate: 2px 0;
	}

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

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

		/*
		 * The one exception to the token. Collapsing the duration would
		 * leave the arrow jumping 2px instantly on hover, which is the
		 * motion without the easing that made it read as motion. It is
		 * decorative, so here it simply does not happen.
		 */
		.pcd-action:hover .pcd-action-icon,
		.pcd-action:focus-visible .pcd-action-icon {
			translate: none;
		}
	}

	@media (forced-colors: active) {
		/* The card, the frame and the control are all fill and opacity, none
		   of which survive here, so each one takes a real border. */
		.profile-card {
			border: 1px solid CanvasText;
		}

		.pcd-frame {
			border-color: CanvasText;
		}

		.pcd-action {
			border: 1px solid CanvasText;
		}

		/* The lone-icon rule above is three selectors' worth of specificity, so
		   it has to be named here or a bare mark stays at 60% for exactly the
		   readers who asked for more contrast, not less. Found by measuring
		   under emulation, not by reading the cascade. */
		.pcd-stat-icon,
		.pcd-stat-icon:first-child:nth-last-child(2),
		.pcd-bio {
			opacity: 1;
		}
	}
</style>

===== END src/components/ProfileCard.astro =====

===== FILE (supporting): src/components/ProfileCard.md =====
# Profile card

A square portrait in a rounded frame, a name with an optional verified badge,
a short bio, and a footer row of marks beside a view control.

Catalog page: `/components/profile-card`

## Purpose

For a single person presented as an object you can act on — a directory
entry, a speaker, an author, a team page cell. The portrait carries most of
the card, which is why it is square and nearly the full width: at this size a
small round avatar would leave the card looking like a form.

Use something else for a list of people. One of these is 320px of vertical
commitment; six in a grid is a page about faces rather than about whatever
the faces are for.

## Files required

**`ProfileCard.astro`** — one file, the whole component.

- No imports at all, not even a type.
- No npm packages, no icon library, no React, no Tailwind, no CSS reset.
- No global stylesheet. Every colour, size and duration is declared on the
  component's own root element as a `--pcd-*` custom property.
- No webfonts. The four icons are inline SVG, so the card makes **no network
  request of its own** beyond the portrait you pass it.
- The only assumption about the host is that Astro compiles `.astro` files.

`ProfileCard.md` — this file. Documentation, not required to run.

The catalog demo uses two portraits in `public/profile-card/`.

## Basic usage

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

<ProfileCard
  name="Ada Whitfield"
  bio="Pre and post-operative breast care specialist."
  image="/people/ada.jpg"
  imageAlt=""
  verified
  stats={[
    { label: "Clinical care", icon: "cross" },
    { label: "Phone", icon: "phone" },
    { label: "Website", icon: "website" },
  ]}
/>
```

Footer items come in two shapes and you can mix them. Leave `value` out and
the item is the icon alone — a mark for something the person offers. Give it
one and the icon becomes a label for a figure:

```astro
stats={[
  { value: "1,219", label: "followers", icon: "followers" },
  { value: "172", label: "photos", icon: "photos" },
]}
```

No client directive, no wrapper, no init call — the component ships its own
module script and boots itself.

It is `width: 100%` up to a 320px cap and centred in whatever it is given, so
it sits in a grid cell or a column without further instruction.

## Props

Everything has a default, including the portrait.

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `name` | `string?` | `"Ada Whitfield"` | Who the card is for. |
| `bio` | `string?` | a sentence | One or two lines under the name. `""` hides it. |
| `image` | `string?` | a placeholder | Portrait. Square; anything else is cropped to the centre. |
| `imageAlt` | `string?` | `""` | Empty marks the portrait decorative, which is right when the name beside it already identifies the person. |
| `verified` | `boolean?` | `false` | Show the check beside the name. |
| `verifiedLabel` | `string?` | `"Verified account"` | Its accessible name. |
| `stats` | `ProfileCardStat[]?` | cross + phone + website | Up to three items. A fourth is dropped rather than allowed to wrap. |
| `actionLabel` | `string?` | `"View"` | Control text. `""` drops the control entirely. |
| `href` | `string?` | — | Renders the control as a link. The component then ships no behaviour at all, and the control is the only form that works without JavaScript. |
| `headingLevel` | `"h2" \| "h3" \| "h4" \| "p"?` | `"h2"` | The name's element. `"p"` for a card inside a list where a heading would be wrong. |
| `background` | `string?` | `#000000` | The card's fill. |
| `ink` | `string?` | `#ffffff` | Name, footer items and control text. Every muted tone derives from it. |
| `accent` | `string?` | `#10b981` | The verified check and the focus ring. |
| `frameColor` | `string?` | `rgb(0 0 0 / 0.1)` | Hairline around the portrait. |
| `radius` | `string?` | `32px` | Card corners. The portrait uses the same. |
| `class` | `string?` | — | Extra class on the root. |
| `id` | `string?` | — | Element id. |

### One footer item

| Field | Type | What it does |
| :--- | :--- | :--- |
| `value` | `string?` | Already formatted — `"1,219"`, `"84.2k"`. Set in tabular figures. Omit it and the item is the icon alone. |
| `label` | `string` | What it is. Not shown; read by assistive technology. With no `value` beside it this is the **only** thing read, so it has to say something on its own. |
| `icon` | `"cross" \| "phone" \| "website" \| "followers" \| "photos" \| "posts" \| "none"?` | Which glyph sits before it, or instead of it. |

A lone icon is drawn at 60% of the ink rather than 40%. Beside a figure the
icon is a label for it and can recede; on its own it *is* the item, and at
40% it reads as disabled.

```ts
import type { ProfileCardStat } from "../components/ProfileCard.astro";
```

### A light card

Every colour is a prop, so the light version is four of them:

```astro
<ProfileCard
  background="#ffffff"
  ink="#0a0a0a"
  frameColor="#e5e7eb"
/>
```

## Behaviour

The control has one state. It is an action, not a switch: nothing about it
changes when you press it, because pressing it is meant to take you somewhere.

| Input | What happens |
| :--- | :--- |
| Click the control | With `href`, it navigates. Without, a `profile-card:view` event fires on the root. |
| <kbd>Enter</kbd> | The same, from the keyboard. <kbd>Space</kbd> too on the button form, as buttons do. |
| <kbd>Tab</kbd> | Reaches the control. It is the only tab stop. |
| Hover or focus | The fill goes from 10% to 20% of the ink and the arrow leans 2px the way it points, both over 150ms. |

### Wiring the view

If the action is a navigation — and it usually is — pass `href`. The control
becomes an anchor, the script never matches it, the card is pure markup and
CSS, and it works with JavaScript switched off:

```astro
<ProfileCard name="Ada Whitfield" href="/people/ada" />
```

Without one you get a button, for the case where viewing means opening a
panel rather than leaving the page. The card does not know which, so it says
it was pressed and leaves the rest to you. The event bubbles and is composed:

```js
document.addEventListener("profile-card:view", (event) => {
  openProfilePanel(event.detail.name);
});
```

### The glyph

One arrow, and it moves 2px on hover inside a box that does not resize, so
the footer cannot reflow and the card cannot change height under the pointer.
Under reduced motion the nudge does not happen at all — collapsing the
duration token would leave it jumping the 2px instantly, which is the motion
without the part that made it read as motion.

## Accessibility

- The name is a real heading, `h2` by default and settable, so the card slots
  into a page outline instead of breaking it.
- **The portrait is decorative by default.** Its `alt` is empty because the
  name sits right beside it; a screen reader announcing "photo of Ada
  Whitfield" immediately before "Ada Whitfield" is noise. Pass `imageAlt` when
  the picture carries something the name does not.
- The verified check is a labelled `img` role, not a decorative flourish —
  "verified" is information.
- **Each footer item says what it is.** `1,219` on its own means nothing and
  a bare icon means less; the word is in the markup, visually hidden, so it is
  read rather than hovered for. The reference shows a bare number beside an
  unlabelled icon. Note what this does and does not buy you: an icon-only item
  announces its label and nothing else, so `"Phone"` tells a screen-reader user
  that this person has a phone, not what the number is. If the marks are meant
  to be contact details, put them in the bio or give the card an `href` to a
  page that has them.
- The control is a real `<button>`, or a real `<a>` when it has somewhere to
  go — never a div, and never a link without an `href`. It has one state and
  no ARIA, because native semantics already say everything true about it.
  The arrow is `aria-hidden`; the label carries the meaning.
- Focus ring is the accent at 2px with 2px of offset, outside the pill.
- Figures are set in tabular numerals, so a count that updates does not
  jiggle the row.
- Under `forced-colors: active` the card, the frame and the control each take
  a real border, since all three are otherwise fill and opacity.

## Reduced motion

Under `prefers-reduced-motion: reduce` the one duration token collapses to
`0.01ms`, so the fill change becomes a cut, and the arrow's hover nudge is
switched off rather than made instant. Nothing rests at zero opacity or zero
size, so the card is correct rather than blank.

## Without JavaScript

The card renders in full: portrait, name, badge, bio and every footer item.

**The button is not rendered.** Without an `href` the only thing the control
can do is report the press, and it cannot do that without script — so it is
served `hidden` and revealed by the controller. A control that cannot act
should not be offered. The reference ships one regardless: focusable,
labelled, inert.

With `href` set there is nothing to hide. The control is an anchor, it works
with no script at all, and this whole paragraph stops applying — which is the
better way to use the component if the action really is a navigation.

## Content length

- **Name** — up to about 18 characters on one line at 320px. Longer wraps, and
  the badge follows the last line.
- **Bio** — 8 to 14 words. Two lines is the design; three is fine, four starts
  to unbalance the portrait above it.
- **Footer items** — three bare icons fit beside the control with room to
  spare (84px of the row's 280). Figures are four or five characters: `84.2k`
  fits; `1,204,338` sends the control to its own line.

## Sizing

`width: 100%` up to a `320px` cap, so it fills a narrower column and never
exceeds the width it was designed at. Everything inside is fixed, because a
320px card has one size.

`margin-inline: auto` centres it in any container wider than the cap, which is
what every other capped component in this library does. In a container
narrower than 320px it has no effect — the card already fills it. If you want
the card hard against the start of its container instead:

```css
.my-column .profile-card {
  margin-inline: 0;
}
```

The footer wraps as a safety valve rather than as a layout: at 320px the
footer items and the control sit on one line, and a longer count or label
sends the control to its own line instead of out of the card.

Retune from CSS without touching the file:

```css
.my-grid .profile-card {
  --pcd-width: 280px;
  --pcd-radius: 12px;
  --pcd-inset: 20px;
  --pcd-duration: 0ms;
}
```

## Differences from the reference

Rebuilt from a live page, measured rather than copied. All 30 values compared
at 1440px landed identical: the 320px card at 8px padding and a 32px radius,
the 304px square frame with its 2px hairline over a 300px portrait, the 24px
body offset, the name at 24px/32px semibold at -1.2px tracking beside a 20px
emerald check, the bio at 16px/28px, and a 48px pill at 12px/24px padding with
a 150ms colour transition. What differs:

- **The copy and the portrait are original.** The reference uses a photograph
  of a named real person; nothing of it, or of its text, is reproduced. The
  demo portraits are abstract figures drawn for this library.
- **The footer does not overflow.** The reference centres a row whose contents
  are 269px wide inside a 253px column, so the figures start 8px left of the
  bio and the control ends 8px right of it — at every width it renders at.
  Here the shared inset is 16px rather than 10/12ths, which gives the row the
  room it needs, and wrapping catches the rest.
- **The card shrinks.** The reference is a fixed `320px`, so at a 280px
  viewport it runs off the side of the screen. This is `width: 100%` under a
  320px cap.
- **The control does something**, and is not rendered when it cannot. The
  reference's is a static demo: a button with no state and no destination.
- **Footer items are labelled** for assistive technology, and figures are set
  in tabular numerals. The default card carries marks rather than counts: a
  profile card for a practitioner says what they do, not how many followers
  they have.
- **It is immune to the host's element styles.** A page with
  `h2 { padding: 0 40px }` moved the reference's name — and moved this one
  too, until a scoped reset was added. See the note in the stylesheet: the
  `:global()` around it is load-bearing.
- The reference sets its text in Geist, a webfont. This uses the system stack,
  because a component in this library fetches nothing.

===== END src/components/ProfileCard.md =====

===== FILE (required): public/profile-card/portrait-1.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" width="800" height="800" role="img">
  <defs>
    <linearGradient id="p1bg" x1="0" y1="0" x2="0.35" y2="1">
      <stop offset="0" stop-color="#efece6"/><stop offset="1" stop-color="#dbd6cd"/></linearGradient>
    <linearGradient id="p1sh" x1="0.05" y1="0.1" x2="0.95" y2="1">
      <stop offset="0" stop-color="#f7ccd6"/><stop offset="0.5" stop-color="#f5e3ba"/>
      <stop offset="1" stop-color="#c7dfd6"/></linearGradient>
  </defs>
  <rect width="800" height="800" fill="url(#p1bg)"/>
  <path d="M356 420h88v150h-88z" fill="#dcc7b6"/>
  <path d="M130 800c0-150 121-272 270-272s270 122 270 272z" fill="url(#p1sh)"/>
  <circle cx="400" cy="300" r="152" fill="#ecdccf"/>
  <path d="M248 296c0-88 68-156 152-156s152 68 152 156c0 26-6 50-16 70 6-40 4-84-20-108-30-30-84-34-116-34-52 0-96 14-124 44-24 26-32 62-28 98-6-22-10-44-10-70z" fill="#241d22"/>
  <path d="M252 286c-14 46-10 96 8 132-30-26-42-74-30-116zM548 286c14 46 10 96-8 132 30-26 42-74 30-116z" fill="#241d22"/>
</svg>

===== END public/profile-card/portrait-1.svg =====

===== FILE (required): public/profile-card/portrait-2.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" width="800" height="800" role="img">
  <defs>
    <linearGradient id="p2bg" x1="0" y1="0" x2="0.4" y2="1">
      <stop offset="0" stop-color="#2a3640"/><stop offset="1" stop-color="#141b21"/></linearGradient>
    <linearGradient id="p2sh" x1="0" y1="0.1" x2="0.9" y2="1">
      <stop offset="0" stop-color="#55798a"/><stop offset="1" stop-color="#2d454f"/></linearGradient>
  </defs>
  <rect width="800" height="800" fill="url(#p2bg)"/>
  <circle cx="596" cy="176" r="58" fill="#e6b269" opacity="0.8"/>
  <path d="M356 420h88v150h-88z" fill="#b8967e"/>
  <path d="M130 800c0-150 121-272 270-272s270 122 270 272z" fill="url(#p2sh)"/>
  <path d="M400 528c-26 0-51 3-74 8l74 70 74-70c-23-5-48-8-74-8z" fill="#e8e2d8" opacity="0.92"/>
  <circle cx="400" cy="300" r="152" fill="#d4b096"/>
  <path d="M256 272c22-78 82-132 144-132s122 54 144 132c-30-46-84-72-144-72s-114 26-144 72z" fill="#1b1512"/>
</svg>

===== END public/profile-card/portrait-2.svg =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
NOT production ready in the library.
Verified 2026-09-18 at 1440, 1024, 768, 480, 390, 360, 320, 280px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source has CHANGED since verification. Treat every result above as expired.

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

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

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

Component reference

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

Read it first
“Profile card” from my Astro Component Library:
https://astro.baysixmedia.com/components/profile-card/

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

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

Component readiness

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

Interactivity
Ships script
Profile
core only
Installation files
3 required + 1 supporting
Verified
2026-09-18
Client navigation
End-to-end passed against a real ClientRouter
Source fingerprint
Source has CHANGED since verification
Production ready
Not yet

Not production ready

Something required failed, is missing, or contradicts a declared fact.

Blocking

  • Component changed after verification — re-run verification and record the new fingerprint.

Verified 2026-09-18 at 1440, 1024, 768, 480, 390, 360, 320, 280px

  • Full-page contextpass
  • Keyboardpass
  • No JavaScriptpass
  • Reduced motionpass
  • Two instancespass
  • No overflowpass
  • Declarations corroboratedpass
  • Lifecyclepass
25 verification notes
  • Every check below was run against the production build served by `astro preview`, not the dev server.
  • Rebuilt from a live page by measurement, not by copying. Read at 1440px in the reference's dark theme and compared on 30 values; all 30 landed identical. A 320px card at 8px padding, a 32px radius and `0 1px 3px rgb(0 0 0 / .2), 0 1px 2px -1px rgb(0 0 0 / .2)`; a 304px square frame with a 2px hairline over a 300px cover-cropped portrait, same 32px radius; a 24px body offset; the name at 24px/32px weight 600 at -1.2px tracking, beside a 20px emerald check at rgb(16,185,129); the bio at 16px/28px at 60 per cent of the ink; figures at 16px/28px weight 600 with 20px icons at 40 per cent; and a 48px pill at 12px/24px padding, 8px gap, ink at 10 per cent rising to 20 on hover over 150ms on cubic-bezier(0.4, 0, 0.2, 1). The figure typography is still in the file and still measures the same - the fixture's second instance renders it - but the default card no longer uses it: its footer carries bare marks rather than counts, so the measured figure row is now an option rather than the default state.
  • The reference's footer row overflows its own column, and this does not. Its figures and control need 269px laid out in the 253px that 10/12ths of the frame gives them, so `justify-content: center` pushes the first figure 8px past the left edge of the bio and the control 8px past the right - measured at 1440, 480, 390, 320 and 280px, so at every width it renders at. Here the shared inset is 12px rather than 10/12ths, the control is pushed over with an auto margin instead of the row being centred, and wrapping catches anything still too wide. Verified: every footer child sits inside the footer box at all eight widths tested, for both the three-mark default and the figure-carrying instance in the fixture. An earlier draft of this note said the inset was 16px; the measured value is 12, and the figure has been corrected rather than left standing.
  • The reference card is a fixed 320px and does not shrink; at a 280px viewport its left edge measures x = -10, so it runs off the side of the screen. This is `width: 100%` under a 320px cap. Measured: 320px wide from 1440 down to 390, then 312, 272 and 232 as the space narrows, with `scrollWidth === clientWidth` and nothing sticking out past the card at any of the eight widths.
  • The card centres itself, and it did not before. A block with a max-width sits at the start of its container, so in a 390px frame at 24px padding the card took the left 320 and left 22px of nothing on the right - visible as a misalignment on mobile, where the leftover is a large fraction of the frame, and present but easy to miss on desktop. `margin-inline: auto` fixes it, which is what 21 of the other capped components in this library already do; this one was the outlier. The stage was the wrong place to fix it: the preview body is deliberately flush so a full-bleed component can reach both edges, and centring there with a grid would stop a 100%-wide component stretching. Measured after: left and right margins equal at 1440, 1024, 768, 480, 390, 375, 360, 320 and 280px - at 390 that is 35.2 against 34.8, the half-pixel being the odd 70px of leftover - and no effect at all below the cap, where the card already fills the row.
  • A real defect was found in the browser and fixed before this record was written, and it would have shipped invisibly. The same card measured 134.2px wide for its name on the preview route and 214.2px on the fixture route - same text, same computed font, same classes. The fixture page styles bare `h2` with `padding: 0 40px`, and an element selector from the host beats a font set on the component root, so the heading silently gained 80px and pushed the verified check to the far edge. Fixed with a scoped reset over the elements a host is likely to style.
  • The fix needed a second fix, which is worth recording because the first attempt looked right and rendered wrong. Written as `.profile-card :where(h1, h2, ...)`, Astro cannot put its scope attribute inside `:where()` so it adds a second one beside it - the emitted selector is `.profile-card[cid] [cid]:where(h2)`, which is three classes' worth of specificity and beats `.pcd-name[cid]` at two. The visible result was a 16px name and a control with no pill. Marking the inner half `:global()` leaves the scope on the ancestor alone, emitting `.profile-card[cid] :where(h2)` at two, which ties with the class rules and loses to them on source order. Verified by reading the compiled CSS, and then by measuring: name 24px and 134.2px wide, control 48px tall with its 999px pill, identical on both routes.
  • The control is a single action, not a toggle, and that is a deliberate departure from the earlier build of this component as well as from the reference. "View" has no pressed counterpart, so there is no `aria-pressed`, no second label and no state to keep in step - a control that says View and then says Viewing would be describing the page you are already on. Two forms: with `href` it is an `<a>` and the component ships no script at all; without one it is a `<button>` that fires `profile-card:view` carrying the name, bubbling and composed, so a listener on the document catches it. Verified end to end on both forms: one activation, one event, and no attribute changes anywhere on the card.
  • Pressing the control does not move the card, and neither does hovering it. The previous toggle had to solve this by rendering both labels in one grid cell, because swapping the text grew the button 20px and wrapped it onto its own line. One label removes the problem at the source. The hover nudge re-raises it in miniature and is answered the same way: the arrow translates 2px inside an icon box of fixed size, so the button's own width never changes. Measured across click, hover and focus: card 512px tall and button 111.1px wide, identical in every one of them, one footer row throughout.
  • One measured value still differs from the reference, and it is the inset: 12px against the reference's effective 25.3px, because the reference's own footer overflows at 10/12ths - see the note above. The control's horizontal padding is back to the measured 24px. It had been cut to 16 to fit "Following" into a row with 126px for it; with one short label that constraint is gone, so the reference value was restored rather than left at a number nothing needed any more.
  • The glyph is one arrow, not a swap. The previous build crossfaded a plus into a tick because there were two states to distinguish; with one state there is nothing to crossfade, so both stacked SVGs and the `d`-morph problem they were avoiding are gone from the file. What is left is a 2px translate on hover and focus, in an icon box that does not resize.
  • The footer carries marks, not counts, and that is a change of content type rather than of styling. Three items - a medical cross, a phone and a globe - each an icon with no figure beside it, saying what the person offers instead of how many of something they have. `value` is now optional on a footer item, the cap went from two items to three, and an item with no figure draws its icon at 60 per cent of the ink instead of 40: beside a number the icon is a label and can recede, alone it IS the item and at 40 per cent it reads as disabled. The cap is measured, not chosen: three 20px marks and their gaps come to 84px of the 280px row, leaving the 111px control its place on the same line at every width down to 390, and wrapping catches the rest.
  • Keyboard: the control is the only tab stop, Enter activates it, Space activates it too in the button form, and the focus ring is the accent at 2px with 2px of offset drawn outside the pill - measured as `2px solid rgb(16, 185, 129)` at `2px` offset. Focus also triggers the arrow nudge, so the hover affordance is not pointer-only. With `href` set the control is an anchor and Enter follows it.
  • Accessibility beyond the reference. The name is a real heading at a settable level rather than a bare h2 in the middle of a page. Each footer item carries a visually hidden word saying what it is, because `1,219` beside an unlabelled icon says nothing and a bare icon says less; the reference ships the bare number. That word is the whole of what an icon-only item announces, which is a real limit and is stated as one in the README: "Phone" tells a screen-reader user that this person has a phone, not what the number is. The verified check is a labelled img role. The portrait is decorative by default with an empty alt, because the name sits beside it and announcing both is noise - `imageAlt` overrides that. Figures are tabular, so a count that changes does not shift the row. The control carries no ARIA at all: it is a `<button>` or an `<a href>`, its accessible name is exactly its one label - measured as "View" - and the arrow is `aria-hidden`.
  • Without JavaScript the card renders in full - portrait, name, badge, bio and all three footer marks - and the button is NOT rendered. It is served `hidden` and revealed by the controller, because without an `href` the only thing the control can do is report the press, and it cannot do that without script; the reference ships its control regardless, focusable and inert. Verified in a scripting-disabled context: no ready flag, the control `display: none`, zero tab stops inside the card, everything else visible, no overflow. The `href` form needs none of this and was checked in the same context: an anchor, never hidden, fully working.
  • Reduced motion: the single duration token collapses to 1e-05s and the fill change becomes a cut. The arrow's hover nudge is the one thing not routed through the token - it is switched off outright, because collapsing the duration would leave the glyph jumping 2px instantly, which is the movement with the part that made it legible removed. Verified under emulation: `translate: none` at rest and on hover, and the control still works and still fires its event.
  • Two instances on one page are independent and were checked as such, and they exercise both forms of the control AND both shapes of footer item rather than two states of one thing. The first is the button, which boots, with three bare marks. The second is given an `href`, so it renders as an anchor, never carries the ready flag, and has no script attached to it at all; it carries figures instead of marks, has no badge, its own label, and a fourth item the component is supposed to drop rather than wrap - it renders three. Activating the first left the second untouched. Zero duplicate ids, no page overflow.
  • Lifecycle: after `astro:before-swap` the ready flag is gone and the button is hidden again. `astro:page-load` boots it again, and dispatching it twice more still produces exactly one event per click. The anchor instance is inert throughout - it has nothing to tear down, which the probe confirms rather than assumes.
  • Client navigation is recorded end-to-end-passed on the strength of a real <ClientRouter /> round trip rather than the dispatched events above - the two are different claims and only the second certifies. A link was followed to the destination route and the browser navigated back: both instances returned, the button carried the ready flag and was visible again, the anchor was back as an anchor, and one click produced exactly one event. Nothing was written to document.body.
  • Forced colours give the card, the portrait frame and the control each a real border, since all three are otherwise fill and opacity. Observed this time under forced-colors emulation rather than declared from the stylesheet: all three resolve to a solid border of non-zero width, and the footer icons and bio come back to full opacity. That last one had to be fixed rather than confirmed: the lone-icon rule that lifts a bare mark to 60 per cent is three selectors' worth of specificity and was quietly beating the forced-colors reset, so the marks sat at 60 per cent for exactly the readers who had asked for more contrast. Caught by measuring under emulation, not by reading the cascade, and the reset now names the same selector.
  • Install kit confirmed from the component's own page: one required file carried inline with its destination path, plus the README as supporting, plus the two portraits declared as assets - four blocks, no placeholder standing in for a file that should have travelled. Every icon is inline SVG, so the card issues no request of its own beyond the portrait it is given.
  • The fingerprint covers three files, not one. A first pass digested only the .astro and the catalog blocked the entry as changed-after-verification, which was correct: declared `assets` are appended to the manifest as REQUIRED installation files, so the portraits are part of the installation and part of what the digest has to cover. Recomputed through the library's own manifest normaliser rather than by hand, over src/components/library/ProfileCard.astro plus both SVGs at their own destinations.
  • Zero console messages and zero page errors across the preview route, the fixture route, the ClientRouter route and the component page.
  • The demo copy and both portraits are original to this library. The reference uses a photograph of a named real person alongside text about them; none of it is reproduced, and the placeholders are abstract figures drawn for this library in the same house style as the other portrait placeholders here.
  • Chromium only. No WebKit or Gecko build was available in this session, and no claim is made about either. The places that gap matters most are `color-mix`, `translate` as a standalone property, and `:first-child:nth-last-child(2)` for the lone-icon opacity - all three are long-settled across engines, but settled is not the same as observed.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/ProfileCard.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/profile-card/portrait-1.svg, public/profile-card/portrait-2.svg. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * Profile card
 *
 * A square portrait in a rounded frame, a name with an optional verified
 * badge, a short bio, and a footer row of counts beside a view control.
 *
 * The control is a single action. Given an `href` it renders as a link and
 * the component ships no behaviour at all; without one it is a button that
 * dispatches an event the host can listen for.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont.
 * Every colour, size and duration is a `--pcd-*` custom property on the root.
 */

export interface ProfileCardStat {
	/**
	 * The figure, already formatted — "1,219", "2.4k", "172". Leave it out
	 * and the item is the icon alone, which is what the default card does:
	 * three marks saying what this person offers, not how many of it.
	 */
	value?: string;
	/**
	 * What it is. Not shown; it is the accessible name for the item, and it
	 * is the ONLY thing read when there is no figure beside it, so it has to
	 * say something on its own.
	 */
	label: string;
	/** Which glyph sits before it, or instead of it. */
	icon?:
		| "cross"
		| "phone"
		| "website"
		| "followers"
		| "photos"
		| "posts"
		| "none";
}

interface Props {
	/** Who the card is for. */
	name?: string;
	/** One or two lines under the name. `""` hides it. */
	bio?: string;
	/** Portrait. Square; anything else is cropped to the centre. */
	image?: string;
	/**
	 * Alternative text. Left empty the portrait is marked decorative, which
	 * is right when the name beside it already identifies the person.
	 */
	imageAlt?: string;
	/** Show the check beside the name. */
	verified?: boolean;
	/** Accessible name for that check. */
	verifiedLabel?: string;
	/** Up to three items in the footer. A fourth is dropped, not wrapped. */
	stats?: ProfileCardStat[];
	/** Footer control text. `""` drops the control entirely. */
	actionLabel?: string;
	/**
	 * Render the control as a link to this URL, which is the only form that
	 * works without JavaScript. Without one the control is a button that
	 * announces itself with a `profile-card:view` event and leaves the rest
	 * to the host.
	 */
	href?: string;
	/** Heading level for the name. */
	headingLevel?: "h2" | "h3" | "h4" | "p";
	/** The card's fill. */
	background?: string;
	/** Name, figures and control text. */
	ink?: string;
	/** The verified check. */
	accent?: string;
	/** Hairline around the portrait. */
	frameColor?: string;
	/** Card corner radius. The portrait uses the same. */
	radius?: string;
	/** Added to the root. */
	class?: string;
	/** Root element id. */
	id?: string;
}

const {
	name = "Ada Whitfield",
	bio = "Pre and post-operative breast care specialist.",
	image = "/profile-card/portrait-1.svg",
	imageAlt = "",
	verified = false,
	verifiedLabel = "Verified account",
	stats = [
		{ label: "Clinical care", icon: "cross" },
		{ label: "Phone", icon: "phone" },
		{ label: "Website", icon: "website" },
	],
	actionLabel = "View",
	href,
	headingLevel = "h2",
	background = "#000000",
	ink = "#ffffff",
	accent = "#10b981",
	frameColor = "rgb(0 0 0 / 0.1)",
	radius = "32px",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/*
 * Only schemes that navigate. The card is copied into other people's
 * projects where the href may come from a profile record rather than from
 * the person writing the template.
 */
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (value?: string) =>
	value && SAFE_SCHEME.test(value.trim()) ? value.trim() : undefined;

const link = safeHref(href);
/* An anchor when there is somewhere to go, a button when there is not. The
   two differ by four attributes, so they are one element rather than two
   branches holding copies of the same glyph. */
const Control = link ? "a" : "button";
const src = safeHref(image);

/* Three is what the footer row has room for beside the control once the
   items are bare icons; a fourth would push the button onto its own line
   rather than fitting. Measured at 320px: three 20px marks and their gaps
   come to 84px of the 280px the row has, leaving the control its 111. */
const figures = stats.slice(0, 3);

/* Icons are inlined rather than fetched, so the card makes no request of its
   own beyond the portrait. Drawn on a 24 grid at stroke 2, like the rest. */
const GLYPHS: Record<string, string[]> = {
	/* An outlined Greek cross rather than a bare plus: the plus is the
	   universal "add", and this has to read as medical at 20px. */
	cross: ["M9 3h6v6h6v6h-6v6H9v-6H3V9h6z"],
	phone: [
		"M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z",
		"M11 18h2",
	],
	/* The ring is drawn as two arcs rather than a <circle>, so every glyph in
	   this table is a path list and the renderer stays one loop. */
	website: [
		"M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20z",
		"M2 12h20",
		"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z",
	],
	followers: ["M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"],
	photos: [
		"M3 5.5A2.5 2.5 0 0 1 5.5 3h13A2.5 2.5 0 0 1 21 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 18.5z",
		"m4 16 4.6-4.6a2 2 0 0 1 2.8 0L16 16",
		"m14 14 1.6-1.6a2 2 0 0 1 2.8 0L20 14",
	],
	posts: [
		"M4 5.5A1.5 1.5 0 0 1 5.5 4h13A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18.5z",
		"M8 9h8M8 13h8M8 17h5",
	],
};

const style = [
	`--pcd-bg:${background}`,
	`--pcd-ink:${ink}`,
	`--pcd-accent:${accent}`,
	`--pcd-frame:${frameColor}`,
	`--pcd-radius:${radius}`,
].join(";");
---

<article
	class:list={["profile-card", className]}
	id={id}
	style={style}
	data-profile-card
>
	<div class="pcd-frame">
		{src ? (
			<img
				class="pcd-photo"
				src={src}
				alt={imageAlt}
				width="800"
				height="800"
				loading="lazy"
				decoding="async"
			/>
		) : (
			<div class="pcd-photo pcd-photo--empty" aria-hidden="true"></div>
		)}
	</div>

	<div class="pcd-body">
		<div class="pcd-nameline">
			<Heading class="pcd-name">{name}</Heading>
			{verified && (
				<svg
					class="pcd-check"
					viewBox="0 0 24 24"
					fill="none"
					stroke="currentColor"
					stroke-width="2"
					stroke-linecap="round"
					stroke-linejoin="round"
					role="img"
					aria-label={verifiedLabel}
				>
					<path
						d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"
					></path>
					<path d="m9 12 2 2 4-4"></path>
				</svg>
			)}
		</div>

		{bio && <p class="pcd-bio">{bio}</p>}

		<div class="pcd-footer">
			{figures.map((stat) => (
				<p class="pcd-stat">
					{stat.icon !== "none" && GLYPHS[stat.icon ?? "followers"] && (
						<svg
							class="pcd-stat-icon"
							viewBox="0 0 24 24"
							fill="none"
							stroke="currentColor"
							stroke-width="2"
							stroke-linecap="round"
							stroke-linejoin="round"
							aria-hidden="true"
						>
							{GLYPHS[stat.icon ?? "followers"].map((d) => <path d={d}></path>)}
							{(stat.icon ?? "followers") === "followers" && (
								<circle cx="12" cy="7" r="4"></circle>
							)}
						</svg>
					)}
					{stat.value && <span class="pcd-stat-value">{stat.value}</span>}
					{/* The figure on its own says "1,219" and nothing else, and an
					    icon on its own says less than that. The word is here rather
					    than in a title attribute so it is read, not hovered for. */}
					<span class="pcd-a11y">{stat.label}</span>
				</p>
			))}

			{actionLabel && (
				/*
				  One element either way.

				  With an `href` it is a link: served visible, works with no
				  JavaScript at all, and nothing in the controller below
				  matches it.

				  Without one it is a button, served `hidden` and revealed
				  once the controller can make it do something. The only thing
				  a button here can do is tell the host it was pressed, and
				  that needs script — so a scripting-disabled page gets no
				  control rather than a dead one. The reference ships a button
				  that takes focus, says its word and does nothing.
				*/
				<Control
					class="pcd-action"
					href={link}
					type={link ? undefined : "button"}
					hidden={link ? undefined : true}
					data-pcd-view={link ? undefined : ""}
				>
					{actionLabel}
					{/* Points the way out of the card. One glyph, no states to
					    keep in step. */}
					<svg
						class="pcd-action-icon"
						viewBox="0 0 24 24"
						fill="none"
						stroke="currentColor"
						stroke-width="2"
						stroke-linecap="round"
						stroke-linejoin="round"
						aria-hidden="true"
					>
						<path d="M5 12h14"></path>
						<path d="m12 5 7 7-7 7"></path>
					</svg>
				</Control>
			)}
		</div>
	</div>
</article>

<script>
	/*
	 * The view control, and only when it is a button.
	 *
	 * Given an `href` the control is a link, none of this matches it, and the
	 * card is markup and CSS with no behaviour at all. Without one the button
	 * has nothing it can do by itself, so it is served `hidden` and revealed
	 * here — a control that cannot act should not be offered. The reference
	 * ships one regardless: focusable, labelled, inert.
	 */
	interface Instance {
		destroy(): void;
	}

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

		const button = root.querySelector<HTMLButtonElement>("[data-pcd-view]");
		if (!button) return undefined;

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

		/* It works from here on, so it can be seen from here on. */
		button.hidden = false;

		button.addEventListener(
			"click",
			() => {
				/*
				 * The card does not know what viewing means in the host's
				 * application — a route, a drawer, a panel — so it says what
				 * happened and lets the host decide. Bubbles and composed, so
				 * a listener on the page catches it.
				 */
				root.dispatchEvent(
					new CustomEvent("profile-card:view", {
						detail: { name: root.querySelector(".pcd-name")?.textContent ?? "" },
						bubbles: true,
						composed: true,
					}),
				);
			},
			{ signal },
		);

		function destroy() {
			button!.hidden = true;
			delete root.dataset.pcdReady;
			controller.abort();
		}

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

		return { destroy };
	}

	const boot = () =>
		document.querySelectorAll<HTMLElement>("[data-profile-card]").forEach(initProfileCard);

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

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

<style>
	.profile-card {
		/*
		 * 320px is the width everything below was measured at. `width: 100%`
		 * under it is the fix for the reference, which is a fixed 320 and
		 * therefore runs off the side of a 280px screen.
		 */
		--pcd-width: 320px;
		--pcd-pad: 8px;
		/*
		 * The inset the name, bio and footer share. The reference uses 10/12
		 * of the frame, which is 25.3px here and leaves the footer 16px too
		 * narrow for its own contents — see the footer rule below.
		 */
		--pcd-inset: 12px;
		--pcd-gap: 12px;

		--pcd-name-size: 1.5rem;
		--pcd-bio-size: 1rem;
		--pcd-stat-size: 1rem;
		--pcd-icon: 20px;

		--pcd-dim: 0.6;
		--pcd-faint: 0.4;
		--pcd-fill: 0.1;
		--pcd-fill-hover: 0.2;

		--pcd-shadow: 0 1px 3px rgb(0 0 0 / 0.2), 0 1px 2px -1px rgb(0 0 0 / 0.2);
		--pcd-ease: cubic-bezier(0.4, 0, 0.2, 1);
		--pcd-duration: 150ms;

		box-sizing: border-box;
		display: block;
		inline-size: 100%;
		max-inline-size: var(--pcd-width);
		/*
		 * Centred in whatever it is given, like every other capped component
		 * in this library. Without it a block with a max-width sits at the
		 * start of its container, which reads as a mistake rather than as a
		 * choice: in a 390px frame at 24px padding the card took the left 320
		 * and left 22px of nothing on the right. No effect at all in a
		 * container narrower than the cap, where the card already fills it.
		 */
		margin-inline: auto;
		padding: var(--pcd-pad);
		border-radius: var(--pcd-radius);
		background: var(--pcd-bg);
		color: var(--pcd-ink);
		box-shadow: var(--pcd-shadow);
		/* A system stack on purpose: a component in this library fetches
		   nothing, so it can never be waiting on a webfont. */
		font-family:
			system-ui,
			-apple-system,
			"Segoe UI",
			Roboto,
			Helvetica,
			Arial,
			sans-serif;
	}

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

	/*
	 * A scoped reset, and it earns its place.
	 *
	 * Everything below sets type on classes, which beats inheritance — but it
	 * does not beat a host rule aimed at the bare element. A page carrying
	 * `h2 { padding: 0 40px }` silently gave the name 80px of padding and
	 * pushed the verified check to the far edge of the card; the font was
	 * right, the class rules were right, and the element was 80px too wide.
	 * Found by measuring the same card on two pages and getting two answers.
	 *
	 * `:where()` keeps this at the specificity of `.profile-card` alone, so
	 * the rules underneath still win on source order while a host's element
	 * selector loses.
	 *
	 * The `:global()` is load-bearing. Astro scopes every compound it can, and
	 * since it cannot put its attribute inside `:where()` it adds a second
	 * one beside it — `.profile-card[cid] [cid]:where(h2)` — which lands at
	 * three classes' worth of specificity and beats `.pcd-name[cid]` at two.
	 * The visible result was a 16px name and a button with no pill. Marking
	 * the inner half global leaves the scope on the ancestor alone, where it
	 * ties with the class rules and loses to them on source order.
	 */
	.profile-card :global(:where(h1, h2, h3, h4, h5, h6, p, span, a, button)) {
		margin: 0;
		padding: 0;
		border: 0;
		background: none;
		font: inherit;
		color: inherit;
		letter-spacing: inherit;
		text-align: inherit;
		text-transform: none;
		text-decoration: none;
	}

	.profile-card :global(:where(button)) {
		appearance: none;
	}

	/* ---- the portrait --------------------------------------------------- */

	.pcd-frame {
		aspect-ratio: 1 / 1;
		overflow: hidden;
		border: 2px solid var(--pcd-frame);
		border-radius: var(--pcd-radius);
	}

	.pcd-photo {
		display: block;
		inline-size: 100%;
		block-size: 100%;
		object-fit: cover;
		object-position: 50% 50%;
	}

	/* No portrait passed: a tinted panel rather than a broken image. */
	.pcd-photo--empty {
		background:
			linear-gradient(140deg, rgb(255 255 255 / 0.12), rgb(255 255 255 / 0.03));
	}

	/* ---- name, bio ------------------------------------------------------ */

	.pcd-body {
		margin-block-start: 24px;
		padding-inline: var(--pcd-inset);
	}

	.pcd-nameline {
		display: flex;
		align-items: center;
		gap: 8px;
	}

	.pcd-name {
		margin: 0;
		font-size: var(--pcd-name-size);
		font-weight: 600;
		line-height: 1.3333;
		/* -0.05em. The name is the one thing set tight enough to notice. */
		letter-spacing: -0.05em;
		color: var(--pcd-ink);
	}

	.pcd-check {
		flex: 0 0 auto;
		inline-size: var(--pcd-icon);
		block-size: var(--pcd-icon);
		color: var(--pcd-accent);
	}

	.pcd-bio {
		margin: 8px 0 0;
		font-size: var(--pcd-bio-size);
		line-height: 1.75;
		color: var(--pcd-ink);
		opacity: var(--pcd-dim);
	}

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

	.pcd-footer {
		display: flex;
		align-items: center;
		gap: var(--pcd-gap);
		/*
		 * Wrapping is the safety valve, not the layout. At the default size
		 * everything sits on one line; a five-figure count or a longer control
		 * label sends the control to its own line instead of out of the card,
		 * which is what the reference does at every width it is rendered at.
		 */
		flex-wrap: wrap;
		margin-block: 24px 0;
	}

	.pcd-stat {
		position: relative;
		display: flex;
		align-items: center;
		gap: 8px;
		margin: 0;
		font-size: var(--pcd-stat-size);
		font-weight: 600;
		line-height: 1.75;
		color: var(--pcd-ink);
	}

	.pcd-stat-icon {
		flex: 0 0 auto;
		inline-size: var(--pcd-icon);
		block-size: var(--pcd-icon);
		opacity: var(--pcd-faint);
	}

	/*
	 * An icon beside a figure is a label for it, and 40% of the ink is enough.
	 * An icon with no figure IS the item, and at 40% it reads as disabled — so
	 * a lone mark comes up to the same 60% the bio sits at. `:only-child`
	 * rather than a modifier class, because the markup already says it: the
	 * visually hidden label is absolutely positioned and out of flow, so an
	 * item with no figure has exactly one child in flow.
	 */
	.pcd-stat-icon:first-child:nth-last-child(2) {
		opacity: var(--pcd-dim);
	}

	.pcd-stat-value {
		font-variant-numeric: tabular-nums;
	}

	/* Read, not seen: the figure alone does not say what it counts. */
	.pcd-a11y {
		position: absolute;
		inline-size: 1px;
		block-size: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
		border: 0;
	}

	.pcd-action {
		/* Pushed to the end rather than centred with the figures, so the row
		   reads as "counts, then the thing you press". */
		margin-inline-start: auto;
		flex: 0 0 auto;
		display: inline-flex;
		align-items: center;
		justify-content: center;
		gap: 8px;
		/*
		 * The reference's 24px, restored. It was 16 for as long as the control
		 * had to hold "Following" as well as "Follow" — 147px of label in a
		 * row with 126px for it. A single short label removes that constraint,
		 * so the measured value goes back.
		 */
		padding: 12px 24px;
		border: 0;
		border-radius: 999px;
		background: color-mix(in srgb, var(--pcd-ink) calc(var(--pcd-fill) * 100%), transparent);
		color: var(--pcd-ink);
		font: inherit;
		font-size: 1rem;
		font-weight: 600;
		line-height: 1.5;
		text-decoration: none;
		cursor: pointer;
		transition: background-color var(--pcd-duration) var(--pcd-ease);
	}

	/* `display: inline-flex` above would otherwise beat the attribute, and the
	   control would be visible and dead before the controller runs. */
	.pcd-action[hidden] {
		display: none;
	}

	.pcd-action:hover {
		background: color-mix(in srgb, var(--pcd-ink) calc(var(--pcd-fill-hover) * 100%), transparent);
	}

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

	.pcd-action-icon {
		flex: 0 0 auto;
		inline-size: var(--pcd-icon);
		block-size: var(--pcd-icon);
		transition: translate var(--pcd-duration) var(--pcd-ease);
	}

	/*
	 * The arrow leans the way it is going. 2px, and the control keeps its own
	 * size — the glyph moves inside a box that does not, so nothing in the
	 * footer reflows and the card cannot change height on hover.
	 */
	.pcd-action:hover .pcd-action-icon,
	.pcd-action:focus-visible .pcd-action-icon {
		translate: 2px 0;
	}

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

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

		/*
		 * The one exception to the token. Collapsing the duration would
		 * leave the arrow jumping 2px instantly on hover, which is the
		 * motion without the easing that made it read as motion. It is
		 * decorative, so here it simply does not happen.
		 */
		.pcd-action:hover .pcd-action-icon,
		.pcd-action:focus-visible .pcd-action-icon {
			translate: none;
		}
	}

	@media (forced-colors: active) {
		/* The card, the frame and the control are all fill and opacity, none
		   of which survive here, so each one takes a real border. */
		.profile-card {
			border: 1px solid CanvasText;
		}

		.pcd-frame {
			border-color: CanvasText;
		}

		.pcd-action {
			border: 1px solid CanvasText;
		}

		/* The lone-icon rule above is three selectors' worth of specificity, so
		   it has to be named here or a bare mark stays at 60% for exactly the
		   readers who asked for more contrast, not less. Found by measuring
		   under emulation, not by reading the cascade. */
		.pcd-stat-icon,
		.pcd-stat-icon:first-child:nth-last-child(2),
		.pcd-bio {
			opacity: 1;
		}
	}
</style>