Component library
Back to catalog

Pebble frame hero

A warm, light split hero: a friendly headline, a welcome, two rounded actions and plain highlights beside a photo in a soft pebble-shaped frame — or, with no photo, a drawn scene of a low sun over rolling hills. Licence, accreditation, founding year and rating render only when supplied.

herowarmfriendlysplitroundedlightno photo needed

Open preview in a new tab

Use this component

Three ways to get Pebble frame hero 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 Pebble frame hero from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/pebble-frame-hero/

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: "Pebble frame hero"
Library page: https://astro.baysixmedia.com/components/pebble-frame-hero/
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/PebbleFrameHero.astro =====
---
/**
 * PebbleFrameHero
 *
 * A warm, light split hero: a friendly headline, a short welcome, two
 * rounded actions and a few plain highlights on one side; on the other, a
 * photo in a soft pebble-shaped frame ringed by two small coloured shapes.
 * With no photo the frame holds a drawn scene instead — a low sun over two
 * rolling hills in the same palette — so the hero reads as designed, not as
 * waiting for an image.
 *
 * Nothing here is a claim unless it is passed. Highlights, licensing,
 * accreditation, a founding year and a rating each render only from real
 * data; left out, they leave no gap, no label and no placeholder.
 *
 * Motion: the copy and the frame settle in once on load (opacity and a few
 * pixels of rise). CSS only, ends at rest, and off under reduced motion.
 *
 * No script. Reads --pfh-* tokens from any ancestor, with fallbacks.
 */

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

export interface PebbleFrameHeroProps {
	heading: string;
	headingLevel?: "h1" | "h2";
	/** A short line above the headline. */
	eyebrow?: string;
	body?: string;
	/** Filled rounded button — "Schedule a tour", "Enrol now". */
	primaryCta?: PebbleFrameHeroAction | null;
	/** Outlined rounded button — "Explore programmes". */
	secondaryCta?: PebbleFrameHeroAction | null;
	/** Plain facts the organisation states about itself: ages served, hours, class sizes. */
	highlights?: readonly string[];
	/** Licence line as issued, e.g. "Licensed by the state, facility #123456789". */
	license?: string;
	/** An accreditation actually held, named as the accrediting body names it. */
	accreditation?: string;
	/** A real founding line, e.g. "Family-run since 2004". */
	established?: string;
	/** Real review data only. Omit it and no rating is shown. */
	rating?: { value: number; count?: number; source?: string } | null;
	/** Photo for the pebble frame. Omit it for the drawn scene. */
	image?: { src: string; alt?: string } | null;
	/** Run the settle-in animation. */
	animate?: boolean;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends PebbleFrameHeroProps {}

const {
	heading,
	headingLevel = "h1",
	eyebrow = "",
	body = "",
	primaryCta = null,
	secondaryCta = null,
	highlights = [],
	license = "",
	accreditation = "",
	established = "",
	rating = null,
	image = null,
	animate = true,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Nunito:wght@500;600;700;800&display=swap",
	class: className,
	id,
} = Astro.props;

const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");

/* A rating is shown only when it is a real number in range. */
const ratingValue =
	rating && typeof rating.value === "number" && Number.isFinite(rating.value) && rating.value >= 0 && rating.value <= 5
		? rating.value
		: null;
const ratingCount =
	ratingValue !== null && typeof rating?.count === "number" && Number.isFinite(rating.count) && rating.count > 0
		? Math.round(rating.count)
		: null;
const ratingText =
	ratingValue === null
		? ""
		: [
				`Rated ${ratingValue.toFixed(1)} out of 5`,
				ratingCount !== null ? `from ${ratingCount.toLocaleString("en-US")} reviews` : "",
				rating?.source ? `on ${rating.source}` : "",
			]
				.filter(Boolean)
				.join(" ");

const points = (highlights ?? []).map((h) => h.trim()).filter(Boolean);
const trust = [license, accreditation, established].map((t) => t.trim()).filter(Boolean);
const photo = image?.src ? { src: image.src, alt: image.alt ?? "" } : null;
const Heading = headingLevel;
---

{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["pfh", className]} id={id} data-animate={animate ? "" : undefined}>
	<div class="pfh-inner">
		<div class="pfh-copy pfh-settle">
			{eyebrow && <p class="pfh-eyebrow">{eyebrow}</p>}
			<Heading class="pfh-title">{heading}</Heading>
			{body && <p class="pfh-body">{body}</p>}

			{
				(primaryCta || secondaryCta) && (
					<div class="pfh-actions">
						{primaryCta && (
							<a class="pfh-btn pfh-btn--primary" href={safeHref(primaryCta.href)}>
								{primaryCta.label}
							</a>
						)}
						{secondaryCta && (
							<a class="pfh-btn pfh-btn--secondary" href={safeHref(secondaryCta.href)}>
								{secondaryCta.label}
							</a>
						)}
					</div>
				)
			}

			{
				points.length > 0 && (
					<ul class="pfh-points" role="list">
						{points.map((point) => (
							<li>
								<span class="pfh-dot" aria-hidden="true" />
								{point}
							</li>
						))}
					</ul>
				)
			}

			{
				(trust.length > 0 || ratingValue !== null) && (
					<ul class="pfh-trust" role="list">
						{trust.map((line) => (
							<li class="pfh-chip">{line}</li>
						))}
						{ratingValue !== null && (
							<li class="pfh-chip">
								<svg class="pfh-star" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
									<path d="m12 2.8 2.8 5.8 6.4.9-4.6 4.5 1.1 6.3L12 17.3l-5.7 3 1.1-6.3-4.6-4.5 6.4-.9Z" fill="currentColor" />
								</svg>
								{ratingText}
							</li>
						)}
					</ul>
				)
			}
		</div>

		<div class="pfh-art pfh-settle" aria-hidden={photo ? undefined : "true"}>
			<span class="pfh-shape pfh-shape--sun" aria-hidden="true"></span>
			<span class="pfh-shape pfh-shape--leaf" aria-hidden="true"></span>
			<div class="pfh-frame">
				{
					photo ? (
						<img src={photo.src} alt={photo.alt} width="640" height="700" loading="eager" decoding="async" />
					) : (
						<div class="pfh-scene">
							<span class="pfh-scene-sun" />
							<span class="pfh-scene-hill pfh-scene-hill--back" />
							<span class="pfh-scene-hill pfh-scene-hill--front" />
						</div>
					)
				}
			</div>
		</div>
	</div>
</section>

<style>
	.pfh {
		--pfh-surface: var(--pfh-bg, #fff8ef);
		--pfh-ink: var(--pfh-text, #2b2a3a);
		--pfh-muted: var(--pfh-text-muted, #5c5a6e);
		--pfh-primary: var(--pfh-brand, #e2674b);
		--pfh-primary-ink: var(--pfh-brand-text, #ffffff);
		--pfh-sage: var(--pfh-accent-1, #9cc5a1);
		--pfh-sun: var(--pfh-accent-2, #f6c65b);
		--pfh-sky: var(--pfh-accent-3, #cfe6f2);
		--pfh-font: var(--pfh-family, Nunito, ui-rounded, ui-sans-serif, system-ui, sans-serif);
		--pfh-ease: cubic-bezier(0.22, 0.8, 0.3, 1);

		overflow: clip;
		background: var(--pfh-surface);
		color: var(--pfh-ink);
		font-family: var(--pfh-font);
	}

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

	.pfh-inner {
		display: grid;
		gap: 40px;
		max-width: 1200px;
		margin: 0 auto;
		padding: 48px 16px 64px;
	}

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

	.pfh-eyebrow {
		margin: 0 0 12px;
		padding: 6px 14px;
		border-radius: 999px;
		background: color-mix(in srgb, var(--pfh-sage) 35%, transparent);
		font: 700 14px/1.3 var(--pfh-font);
	}

	.pfh-title {
		margin: 0;
		max-width: 16ch;
		font: 800 clamp(34px, 7.5vw, 56px) / 1.1 var(--pfh-font);
		letter-spacing: -0.01em;
		overflow-wrap: anywhere;
	}

	.pfh-body {
		max-width: 52ch;
		margin: 18px 0 0;
		color: var(--pfh-muted);
		font: 600 18px/1.6 var(--pfh-font);
		overflow-wrap: anywhere;
	}

	.pfh-actions {
		display: flex;
		flex-wrap: wrap;
		gap: 12px;
		margin-top: 28px;
	}

	.pfh-btn {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		min-height: 50px;
		padding: 12px 26px;
		border: 2px solid var(--pfh-primary);
		border-radius: 999px;
		font: 800 17px/1.2 var(--pfh-font);
		text-align: center;
		text-decoration: none;
		transition:
			background-color 0.2s ease,
			color 0.2s ease;
	}

	.pfh-btn--primary {
		background: var(--pfh-primary);
		color: var(--pfh-primary-ink);
	}

	.pfh-btn--primary:hover {
		background: var(--pfh-ink);
		border-color: var(--pfh-ink);
	}

	.pfh-btn--secondary {
		background: transparent;
		color: var(--pfh-ink);
	}

	.pfh-btn--secondary:hover {
		background: color-mix(in srgb, var(--pfh-primary) 12%, transparent);
	}

	.pfh-btn:focus-visible {
		outline: 3px solid var(--pfh-ink);
		outline-offset: 3px;
	}

	.pfh-points {
		display: grid;
		gap: 10px;
		margin: 28px 0 0;
		padding: 0;
		list-style: none;
	}

	.pfh-points li {
		display: flex;
		align-items: flex-start;
		gap: 10px;
		font: 700 16px/1.5 var(--pfh-font);
		overflow-wrap: anywhere;
	}

	.pfh-dot {
		flex: none;
		width: 12px;
		height: 12px;
		margin-top: 6px;
		border-radius: 50%;
		background: var(--pfh-sage);
	}

	.pfh-trust {
		display: flex;
		flex-wrap: wrap;
		gap: 8px;
		margin: 24px 0 0;
		padding: 0;
		list-style: none;
	}

	.pfh-chip {
		display: inline-flex;
		align-items: center;
		gap: 6px;
		padding: 8px 14px;
		border-radius: 14px;
		background: #fff;
		box-shadow: 0 1px 0 rgb(43 42 58 / 0.08);
		color: var(--pfh-muted);
		font: 700 14px/1.3 var(--pfh-font);
		overflow-wrap: anywhere;
	}

	.pfh-star {
		flex: none;
		width: 16px;
		height: 16px;
		color: #d98a0b;
	}

	/* ---- the pebble frame ---- */
	.pfh-art {
		position: relative;
		width: min(100%, 520px);
		margin: 0 auto;
		padding: 18px;
	}

	.pfh-frame {
		position: relative;
		aspect-ratio: 9 / 10;
		overflow: hidden;
		border-radius: 46% 54% 42% 58% / 52% 44% 56% 48%;
		background: var(--pfh-sky);
	}

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

	.pfh-shape {
		position: absolute;
		z-index: 1;
		border-radius: 50%;
	}

	/* A ring in the button colour, so it frames the pebble rather than
	   repeating the sun drawn inside it. */
	.pfh-shape--sun {
		top: 2%;
		right: 6%;
		width: 20%;
		aspect-ratio: 1;
		border: clamp(8px, 2.2vw, 14px) solid color-mix(in srgb, var(--pfh-primary) 70%, #fff);
		background: transparent;
	}

	.pfh-shape--leaf {
		bottom: 4%;
		left: 0;
		width: 26%;
		aspect-ratio: 1;
		border-radius: 0 60% 0 60%;
		background: var(--pfh-sage);
	}

	/* The drawn scene: a low sun behind two rolling hills. */
	.pfh-scene {
		position: absolute;
		inset: 0;
		background: linear-gradient(180deg, var(--pfh-sky) 0%, color-mix(in srgb, var(--pfh-sky) 60%, #fff) 70%);
	}

	.pfh-scene-sun {
		position: absolute;
		top: 24%;
		left: 50%;
		width: 34%;
		aspect-ratio: 1;
		border-radius: 50%;
		background: var(--pfh-sun);
		transform: translateX(-50%);
	}

	.pfh-scene-hill {
		position: absolute;
		bottom: -18%;
		width: 110%;
		aspect-ratio: 1.6;
		border-radius: 50%;
	}

	.pfh-scene-hill--back {
		left: -40%;
		background: color-mix(in srgb, var(--pfh-sage) 70%, #fff);
	}

	.pfh-scene-hill--front {
		right: -45%;
		bottom: -30%;
		background: var(--pfh-sage);
	}

	/* ---- settle-in: ends at rest, nothing hidden without CSS animation ---- */
	@keyframes pfh-settle {
		from {
			opacity: 0;
			transform: translateY(12px);
		}
	}

	.pfh[data-animate] .pfh-settle {
		animation: pfh-settle 0.7s var(--pfh-ease) both;
	}

	.pfh[data-animate] .pfh-art {
		animation-delay: 0.12s;
	}

	@media (min-width: 900px) {
		.pfh-inner {
			grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
			align-items: center;
			gap: 56px;
			padding: 88px 32px;
		}

		.pfh-art {
			margin: 0;
			justify-self: end;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.pfh[data-animate] .pfh-settle {
			animation: none;
		}

		.pfh-btn {
			transition: none;
		}
	}
</style>

===== END src/components/PebbleFrameHero.astro =====

===== FILE (supporting): src/components/PebbleFrameHero.md =====
# Pebble frame hero

A warm, light split hero for organisations that welcome people in —
childcare and early learning, tutoring, family services, a community clinic.
A friendly headline, a short welcome, two rounded actions and a few plain
highlights sit beside a photo in a soft pebble-shaped frame, ringed by two
small coloured shapes. With no photo the frame holds a drawn scene instead: a
low sun over two rolling hills in the same palette.

Catalog page: `/components/pebble-frame-hero`

## Files required

- **`PebbleFrameHero.astro`** — the whole component. No script, no images.
- `PebbleFrameHero.md` — this file.

## Basic usage

```astro
<PebbleFrameHero
  heading="Where little ones love to learn"
  body="Play-based early learning in small groups, five minutes from downtown."
  primaryCta={{ label: "Schedule a tour", href: "/tour" }}
  secondaryCta={{ label: "Explore programs", href: "/programs" }}
  highlights={["Ages 2 to 5", "Open weekdays, 7:30am to 6pm"]}
  image={{ src: "/img/classroom.webp", alt: "Children painting at a low table" }}
/>
```

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `heading` | `string` | **required** | The page's headline. |
| `headingLevel` | `"h1" \| "h2"` | `"h1"` | `h2` for a second use on one page. |
| `eyebrow` | `string?` | `""` | Rounded chip above the headline. |
| `body` | `string?` | `""` | The welcome. |
| `primaryCta` | `{ label, href } \| null` | `null` | Filled rounded button. |
| `secondaryCta` | `{ label, href } \| null` | `null` | Outlined rounded button. |
| `highlights` | `string[]?` | `[]` | Plain facts, each with a dot. |
| `license` | `string?` | `""` | Licence line as issued. |
| `accreditation` | `string?` | `""` | An accreditation actually held. |
| `established` | `string?` | `""` | A real founding line. |
| `rating` | `{ value, count?, source? } \| null` | `null` | Real review data only. |
| `image` | `{ src, alt? } \| null` | `null` | Photo for the pebble frame. |
| `animate` | `boolean?` | `true` | The settle-in on load. |
| `fontStylesheet` | `string?` | Nunito | `""` skips it. |
| `class`, `id` | | | Root attributes. |

The call-to-action labels are props: "Schedule a tour", "Explore programs",
"Contact us" — whatever the organisation actually offers.

## Trust data

Highlights, licence, accreditation, founding line and rating render only when
passed, as rounded chips under the actions. None has a default. A rating must
be a number from 0 to 5; anything else is ignored. The review count and
source are optional and never assumed.

## Media

- **With `image`:** the photo fills the pebble frame, `object-fit: cover`.
  Give it an `alt` unless it is purely decorative.
- **Without:** the frame holds the drawn scene (a sun circle and two hill
  shapes), marked `aria-hidden`. There is no placeholder text and no demo
  photo: the component ships no imagery of people.

## Responsive behaviour

- Stacked below 900px: copy first, frame after it, centred and capped at
  520px.
- Two columns (1.1fr / 1fr) from 900px, frame on the right.

## Motion

On load the copy and the frame rise 12px and fade in over 0.7s, the frame
0.12s later. CSS animations that end at rest, so nothing is hidden without
them; removed under `prefers-reduced-motion: reduce`, as are the button colour
transitions. `animate={false}` turns the settle-in off.

## Accessibility

- One `<h1>` by default.
- Buttons are real links with a 3px focus outline.
- Highlights and trust chips are lists; decorative shapes are `aria-hidden`.

## Tokens

`--pfh-bg`, `--pfh-text`, `--pfh-text-muted`, `--pfh-brand`,
`--pfh-brand-text`, `--pfh-accent-1` (sage), `--pfh-accent-2` (sun),
`--pfh-accent-3` (sky), `--pfh-family`.

## Selection

Friendly and professional in tone, subtle motion, direct pressure. No market
is recorded: nothing in it is specific to one industry, and its warmth is a
tone rather than a market. It needs no client material; a photo is an
alternate presentation.

===== END src/components/PebbleFrameHero.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
“Pebble frame hero” from my Astro Component Library:
https://astro.baysixmedia.com/components/pebble-frame-hero/

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/PebbleFrameHero.astro

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

---
/**
 * PebbleFrameHero
 *
 * A warm, light split hero: a friendly headline, a short welcome, two
 * rounded actions and a few plain highlights on one side; on the other, a
 * photo in a soft pebble-shaped frame ringed by two small coloured shapes.
 * With no photo the frame holds a drawn scene instead — a low sun over two
 * rolling hills in the same palette — so the hero reads as designed, not as
 * waiting for an image.
 *
 * Nothing here is a claim unless it is passed. Highlights, licensing,
 * accreditation, a founding year and a rating each render only from real
 * data; left out, they leave no gap, no label and no placeholder.
 *
 * Motion: the copy and the frame settle in once on load (opacity and a few
 * pixels of rise). CSS only, ends at rest, and off under reduced motion.
 *
 * No script. Reads --pfh-* tokens from any ancestor, with fallbacks.
 */

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

export interface PebbleFrameHeroProps {
	heading: string;
	headingLevel?: "h1" | "h2";
	/** A short line above the headline. */
	eyebrow?: string;
	body?: string;
	/** Filled rounded button — "Schedule a tour", "Enrol now". */
	primaryCta?: PebbleFrameHeroAction | null;
	/** Outlined rounded button — "Explore programmes". */
	secondaryCta?: PebbleFrameHeroAction | null;
	/** Plain facts the organisation states about itself: ages served, hours, class sizes. */
	highlights?: readonly string[];
	/** Licence line as issued, e.g. "Licensed by the state, facility #123456789". */
	license?: string;
	/** An accreditation actually held, named as the accrediting body names it. */
	accreditation?: string;
	/** A real founding line, e.g. "Family-run since 2004". */
	established?: string;
	/** Real review data only. Omit it and no rating is shown. */
	rating?: { value: number; count?: number; source?: string } | null;
	/** Photo for the pebble frame. Omit it for the drawn scene. */
	image?: { src: string; alt?: string } | null;
	/** Run the settle-in animation. */
	animate?: boolean;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends PebbleFrameHeroProps {}

const {
	heading,
	headingLevel = "h1",
	eyebrow = "",
	body = "",
	primaryCta = null,
	secondaryCta = null,
	highlights = [],
	license = "",
	accreditation = "",
	established = "",
	rating = null,
	image = null,
	animate = true,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Nunito:wght@500;600;700;800&display=swap",
	class: className,
	id,
} = Astro.props;

const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");

/* A rating is shown only when it is a real number in range. */
const ratingValue =
	rating && typeof rating.value === "number" && Number.isFinite(rating.value) && rating.value >= 0 && rating.value <= 5
		? rating.value
		: null;
const ratingCount =
	ratingValue !== null && typeof rating?.count === "number" && Number.isFinite(rating.count) && rating.count > 0
		? Math.round(rating.count)
		: null;
const ratingText =
	ratingValue === null
		? ""
		: [
				`Rated ${ratingValue.toFixed(1)} out of 5`,
				ratingCount !== null ? `from ${ratingCount.toLocaleString("en-US")} reviews` : "",
				rating?.source ? `on ${rating.source}` : "",
			]
				.filter(Boolean)
				.join(" ");

const points = (highlights ?? []).map((h) => h.trim()).filter(Boolean);
const trust = [license, accreditation, established].map((t) => t.trim()).filter(Boolean);
const photo = image?.src ? { src: image.src, alt: image.alt ?? "" } : null;
const Heading = headingLevel;
---

{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<section class:list={["pfh", className]} id={id} data-animate={animate ? "" : undefined}>
	<div class="pfh-inner">
		<div class="pfh-copy pfh-settle">
			{eyebrow && <p class="pfh-eyebrow">{eyebrow}</p>}
			<Heading class="pfh-title">{heading}</Heading>
			{body && <p class="pfh-body">{body}</p>}

			{
				(primaryCta || secondaryCta) && (
					<div class="pfh-actions">
						{primaryCta && (
							<a class="pfh-btn pfh-btn--primary" href={safeHref(primaryCta.href)}>
								{primaryCta.label}
							</a>
						)}
						{secondaryCta && (
							<a class="pfh-btn pfh-btn--secondary" href={safeHref(secondaryCta.href)}>
								{secondaryCta.label}
							</a>
						)}
					</div>
				)
			}

			{
				points.length > 0 && (
					<ul class="pfh-points" role="list">
						{points.map((point) => (
							<li>
								<span class="pfh-dot" aria-hidden="true" />
								{point}
							</li>
						))}
					</ul>
				)
			}

			{
				(trust.length > 0 || ratingValue !== null) && (
					<ul class="pfh-trust" role="list">
						{trust.map((line) => (
							<li class="pfh-chip">{line}</li>
						))}
						{ratingValue !== null && (
							<li class="pfh-chip">
								<svg class="pfh-star" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
									<path d="m12 2.8 2.8 5.8 6.4.9-4.6 4.5 1.1 6.3L12 17.3l-5.7 3 1.1-6.3-4.6-4.5 6.4-.9Z" fill="currentColor" />
								</svg>
								{ratingText}
							</li>
						)}
					</ul>
				)
			}
		</div>

		<div class="pfh-art pfh-settle" aria-hidden={photo ? undefined : "true"}>
			<span class="pfh-shape pfh-shape--sun" aria-hidden="true"></span>
			<span class="pfh-shape pfh-shape--leaf" aria-hidden="true"></span>
			<div class="pfh-frame">
				{
					photo ? (
						<img src={photo.src} alt={photo.alt} width="640" height="700" loading="eager" decoding="async" />
					) : (
						<div class="pfh-scene">
							<span class="pfh-scene-sun" />
							<span class="pfh-scene-hill pfh-scene-hill--back" />
							<span class="pfh-scene-hill pfh-scene-hill--front" />
						</div>
					)
				}
			</div>
		</div>
	</div>
</section>

<style>
	.pfh {
		--pfh-surface: var(--pfh-bg, #fff8ef);
		--pfh-ink: var(--pfh-text, #2b2a3a);
		--pfh-muted: var(--pfh-text-muted, #5c5a6e);
		--pfh-primary: var(--pfh-brand, #e2674b);
		--pfh-primary-ink: var(--pfh-brand-text, #ffffff);
		--pfh-sage: var(--pfh-accent-1, #9cc5a1);
		--pfh-sun: var(--pfh-accent-2, #f6c65b);
		--pfh-sky: var(--pfh-accent-3, #cfe6f2);
		--pfh-font: var(--pfh-family, Nunito, ui-rounded, ui-sans-serif, system-ui, sans-serif);
		--pfh-ease: cubic-bezier(0.22, 0.8, 0.3, 1);

		overflow: clip;
		background: var(--pfh-surface);
		color: var(--pfh-ink);
		font-family: var(--pfh-font);
	}

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

	.pfh-inner {
		display: grid;
		gap: 40px;
		max-width: 1200px;
		margin: 0 auto;
		padding: 48px 16px 64px;
	}

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

	.pfh-eyebrow {
		margin: 0 0 12px;
		padding: 6px 14px;
		border-radius: 999px;
		background: color-mix(in srgb, var(--pfh-sage) 35%, transparent);
		font: 700 14px/1.3 var(--pfh-font);
	}

	.pfh-title {
		margin: 0;
		max-width: 16ch;
		font: 800 clamp(34px, 7.5vw, 56px) / 1.1 var(--pfh-font);
		letter-spacing: -0.01em;
		overflow-wrap: anywhere;
	}

	.pfh-body {
		max-width: 52ch;
		margin: 18px 0 0;
		color: var(--pfh-muted);
		font: 600 18px/1.6 var(--pfh-font);
		overflow-wrap: anywhere;
	}

	.pfh-actions {
		display: flex;
		flex-wrap: wrap;
		gap: 12px;
		margin-top: 28px;
	}

	.pfh-btn {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		min-height: 50px;
		padding: 12px 26px;
		border: 2px solid var(--pfh-primary);
		border-radius: 999px;
		font: 800 17px/1.2 var(--pfh-font);
		text-align: center;
		text-decoration: none;
		transition:
			background-color 0.2s ease,
			color 0.2s ease;
	}

	.pfh-btn--primary {
		background: var(--pfh-primary);
		color: var(--pfh-primary-ink);
	}

	.pfh-btn--primary:hover {
		background: var(--pfh-ink);
		border-color: var(--pfh-ink);
	}

	.pfh-btn--secondary {
		background: transparent;
		color: var(--pfh-ink);
	}

	.pfh-btn--secondary:hover {
		background: color-mix(in srgb, var(--pfh-primary) 12%, transparent);
	}

	.pfh-btn:focus-visible {
		outline: 3px solid var(--pfh-ink);
		outline-offset: 3px;
	}

	.pfh-points {
		display: grid;
		gap: 10px;
		margin: 28px 0 0;
		padding: 0;
		list-style: none;
	}

	.pfh-points li {
		display: flex;
		align-items: flex-start;
		gap: 10px;
		font: 700 16px/1.5 var(--pfh-font);
		overflow-wrap: anywhere;
	}

	.pfh-dot {
		flex: none;
		width: 12px;
		height: 12px;
		margin-top: 6px;
		border-radius: 50%;
		background: var(--pfh-sage);
	}

	.pfh-trust {
		display: flex;
		flex-wrap: wrap;
		gap: 8px;
		margin: 24px 0 0;
		padding: 0;
		list-style: none;
	}

	.pfh-chip {
		display: inline-flex;
		align-items: center;
		gap: 6px;
		padding: 8px 14px;
		border-radius: 14px;
		background: #fff;
		box-shadow: 0 1px 0 rgb(43 42 58 / 0.08);
		color: var(--pfh-muted);
		font: 700 14px/1.3 var(--pfh-font);
		overflow-wrap: anywhere;
	}

	.pfh-star {
		flex: none;
		width: 16px;
		height: 16px;
		color: #d98a0b;
	}

	/* ---- the pebble frame ---- */
	.pfh-art {
		position: relative;
		width: min(100%, 520px);
		margin: 0 auto;
		padding: 18px;
	}

	.pfh-frame {
		position: relative;
		aspect-ratio: 9 / 10;
		overflow: hidden;
		border-radius: 46% 54% 42% 58% / 52% 44% 56% 48%;
		background: var(--pfh-sky);
	}

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

	.pfh-shape {
		position: absolute;
		z-index: 1;
		border-radius: 50%;
	}

	/* A ring in the button colour, so it frames the pebble rather than
	   repeating the sun drawn inside it. */
	.pfh-shape--sun {
		top: 2%;
		right: 6%;
		width: 20%;
		aspect-ratio: 1;
		border: clamp(8px, 2.2vw, 14px) solid color-mix(in srgb, var(--pfh-primary) 70%, #fff);
		background: transparent;
	}

	.pfh-shape--leaf {
		bottom: 4%;
		left: 0;
		width: 26%;
		aspect-ratio: 1;
		border-radius: 0 60% 0 60%;
		background: var(--pfh-sage);
	}

	/* The drawn scene: a low sun behind two rolling hills. */
	.pfh-scene {
		position: absolute;
		inset: 0;
		background: linear-gradient(180deg, var(--pfh-sky) 0%, color-mix(in srgb, var(--pfh-sky) 60%, #fff) 70%);
	}

	.pfh-scene-sun {
		position: absolute;
		top: 24%;
		left: 50%;
		width: 34%;
		aspect-ratio: 1;
		border-radius: 50%;
		background: var(--pfh-sun);
		transform: translateX(-50%);
	}

	.pfh-scene-hill {
		position: absolute;
		bottom: -18%;
		width: 110%;
		aspect-ratio: 1.6;
		border-radius: 50%;
	}

	.pfh-scene-hill--back {
		left: -40%;
		background: color-mix(in srgb, var(--pfh-sage) 70%, #fff);
	}

	.pfh-scene-hill--front {
		right: -45%;
		bottom: -30%;
		background: var(--pfh-sage);
	}

	/* ---- settle-in: ends at rest, nothing hidden without CSS animation ---- */
	@keyframes pfh-settle {
		from {
			opacity: 0;
			transform: translateY(12px);
		}
	}

	.pfh[data-animate] .pfh-settle {
		animation: pfh-settle 0.7s var(--pfh-ease) both;
	}

	.pfh[data-animate] .pfh-art {
		animation-delay: 0.12s;
	}

	@media (min-width: 900px) {
		.pfh-inner {
			grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
			align-items: center;
			gap: 56px;
			padding: 88px 32px;
		}

		.pfh-art {
			margin: 0;
			justify-self: end;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.pfh[data-animate] .pfh-settle {
			animation: none;
		}

		.pfh-btn {
			transition: none;
		}
	}
</style>