Component library
Back to catalog

Project brief form

A dark intake form built out of questions rather than labels, with multi-choice answers as pills you select instead of boxes you tick.

contactformbriefpillsdarkstatic

Open preview in a new tab

Use this component

Three ways to get Project brief form 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 Project brief form from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/project-brief-form/

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: "Project brief form"
Library page: https://astro.baysixmedia.com/components/project-brief-form/
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/ProjectBriefForm.astro =====
---
/**
 * Project brief form
 *
 * A dark intake form built out of questions: each field is asked rather than
 * labelled, and the multi-choice answers are pills you select instead of
 * boxes you tick. Text fields, then any number of choice groups, then a
 * submit row with a fallback address.
 *
 * Static: no script at all. Every control is a native form element, so
 * validation, keyboard behaviour and submission are the browser's, and the
 * whole thing works with JavaScript switched off.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont.
 * Every colour and measurement is a `--pbf-*` custom property on the root.
 */

export interface BriefField {
	/** Submitted under this name. */
	name: string;
	/** The question. Rendered uppercase by the stylesheet, not the string. */
	label: string;
	/** Native input type, or a textarea. */
	type?: "text" | "email" | "url" | "tel" | "date" | "textarea";
	/** A hint, never a label. */
	placeholder?: string;
	required?: boolean;
	/** `half` lets two fields share a row above the breakpoint. */
	span?: "half" | "full";
	/** Passed straight through, so a browser can fill the field. */
	autocomplete?: string;
	/** Rows for a textarea. Ignored otherwise. */
	rows?: number;
}

export interface BriefChoiceGroup {
	/** Submitted under this name. Checkboxes repeat it; radios share it. */
	name: string;
	/** The question above the pills. */
	legend: string;
	/** Many or one. */
	type?: "checkbox" | "radio";
	/** The pills. A string is its own value. */
	options: (string | { label: string; value?: string })[];
	/** Radio groups only: require an answer. */
	required?: boolean;
}

interface Props {
	/** Optional heading above the questions. `""` drops it. */
	heading?: string;
	/** Heading level, so it fits the page outline. */
	headingLevel?: "h1" | "h2" | "h3";
	/** The text questions, in order. */
	fields?: BriefField[];
	/** The pill groups, in order, after the text questions. Defaults to one generic deadline question. */
	groups?: BriefChoiceGroup[];
	/**
	 * Where the form posts. Left unset the browser submits to the current
	 * URL — fine for a preview, wrong for a site. Point it at a handler.
	 */
	action?: string;
	/** `post` unless you have a reason. */
	method?: "get" | "post";
	/** Added to the <form>, for handlers that route on it. */
	formName?: string;
	/** The submit button. */
	submitLabel?: string;
	/** The words before the fallback address. `""` drops the line. */
	footerText?: string;
	/** The fallback address. `""` leaves the text with no link. */
	email?: string;
	/** Behind the panel, edge to edge. */
	background?: string;
	/** The panel itself. */
	panel?: string;
	/** Questions, answers, the submit pill. */
	ink?: string;
	/** Placeholders and the footer line. */
	mutedInk?: string;
	/** Field fill. Translucent over the panel by default. */
	fieldBackground?: string;
	/** Hairlines: field edges, pill edges, the rules between sections. */
	borderColor?: string;
	/** A selected pill and the focus ring. */
	accent?: string;
	/** Text on a selected pill. */
	accentInk?: string;
	/** Where the panel stops widening. */
	maxWidth?: string;
	/** Added to the root. */
	class?: string;
	/** Root element id. Also seeds every generated id. */
	id?: string;
}

const {
	heading = "",
	headingLevel = "h2",
	fields = [
		{ name: "name", label: "What's your name?", placeholder: "Full name", required: true, span: "half", autocomplete: "name" },
		{ name: "email", label: "What's your email?", type: "email", placeholder: "you@company.com", required: true, span: "half", autocomplete: "email" },
		{ name: "brief", label: "What's your brief?", type: "textarea", placeholder: "Two or three paragraphs is plenty…", required: true, span: "full", rows: 4 },
		{ name: "website", label: "Current website", type: "url", placeholder: "www.example.com", span: "half", autocomplete: "url" },
		{ name: "start", label: "When would you like to start?", placeholder: "DD / MM / YYYY", span: "half" },
	],
	/*
	 * Only the question any brief can ask. A services list and budget bands
	 * describe a particular business and its prices, so they are not assumed:
	 * the demo passes them.
	 */
	groups = [
		{
			name: "deadline",
			legend: "Do you have a deadline?",
			type: "radio",
			options: ["Yes, a fixed one", "No, no rush", "No, but soon"],
		},
	],
	action,
	method = "post",
	formName,
	submitLabel = "Send the brief",
	footerText = "Or write to us directly at",
	/* No default address: an email is a fact about a real business. The demo passes one. */
	email = "",
	background = "#1d1d1d",
	panel = "#252525",
	ink = "#e5e4e0",
	mutedInk = "#999999",
	fieldBackground = "rgb(218 218 218 / 0.2)",
	borderColor = "rgb(218 218 218 / 0.2)",
	accent = "#e5e4e0",
	accentInk = "#1d1d1d",
	maxWidth = "1165px",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/*
 * Every control needs an id its label can point at, and two of these on one
 * page must not collide. Derived from `id` when the caller gives one, random
 * otherwise — an index-derived id would collide on the second instance.
 */
const uid = id ?? `pbf-${Math.random().toString(36).slice(2, 9)}`;
/* `uid` can arrive from the `id` prop, which is caller data, and it goes into
   id and `for` attributes; anything that is not an identifier character is
   stripped before it gets there. */
const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, "") || "pbf";
const fid = (name: string) => `${safeUid}-${name.replace(/[^A-Za-z0-9_-]/g, "") || "f"}`;

const textFields = (fields ?? []).filter((f) => f?.name && f?.label);
const choiceGroups = (groups ?? []).filter((g) => g?.name && g?.legend && g.options?.length);

/*
 * Rows are built here rather than in the template, so a `half` with nothing
 * to pair with still gets a row of its own instead of a half-width field
 * stranded beside empty space.
 */
const rows: BriefField[][] = [];
for (const field of textFields) {
	const last = rows[rows.length - 1];
	if (field.span === "half" && last && last.length === 1 && last[0].span === "half") {
		last.push(field);
	} else {
		rows.push([field]);
	}
}

const optionOf = (o: string | { label: string; value?: string }) =>
	typeof o === "string" ? { label: o, value: o } : { label: o.label, value: o.value ?? o.label };

const mail = email?.trim();

const style = [
	`--pbf-bg:${background}`,
	`--pbf-panel:${panel}`,
	`--pbf-ink:${ink}`,
	`--pbf-muted:${mutedInk}`,
	`--pbf-field:${fieldBackground}`,
	`--pbf-border:${borderColor}`,
	`--pbf-accent:${accent}`,
	`--pbf-accent-ink:${accentInk}`,
	`--pbf-max:${maxWidth}`,
].join(";");
---

<section class:list={["project-brief-form", className]} id={id} style={style} data-project-brief-form>
	<div class="pbf-panel">
		{heading && <Heading class="pbf-heading">{heading}</Heading>}

		<form class="pbf-form" action={action} method={method} name={formName}>
			{rows.map((row) => (
				<div class:list={["pbf-row", row.length > 1 && "pbf-row--split"]}>
					{row.map((field) => (
						<div class="pbf-field">
							{/*
							  A real label with a real `for`. The reference asks the
							  question in a <span> and gives the input no label at
							  all, so every field is named by its placeholder alone —
							  and a placeholder disappears the moment you type.
							*/}
							<label class="pbf-label" for={fid(field.name)}>{field.label}</label>

							{field.type === "textarea" ? (
								<textarea
									class="pbf-input pbf-textarea"
									id={fid(field.name)}
									name={field.name}
									rows={field.rows ?? 4}
									placeholder={field.placeholder}
									required={field.required}
									autocomplete={field.autocomplete}></textarea>
							) : (
								<input
									class="pbf-input"
									type={field.type ?? "text"}
									id={fid(field.name)}
									name={field.name}
									placeholder={field.placeholder}
									required={field.required}
									autocomplete={field.autocomplete}
								/>
							)}
						</div>
					))}
				</div>
			))}

			{choiceGroups.map((group) => (
				/* A real fieldset with a real legend, which is what groups a set
				   of related controls for assistive technology. */
				<fieldset class="pbf-group">
					<legend class="pbf-legend">{group.legend}</legend>
					<div class="pbf-pills">
						{group.options.map((raw, i) => {
							const option = optionOf(raw);
							const inputId = fid(`${group.name}-${i}`);
							return (
								<label class="pbf-pill" for={inputId}>
									{/*
									  The real control, stretched over the whole pill
									  rather than parked in a corner. The reference
									  leaves its input 13px square and invisible, so
									  the only thing a keyboard ring could land on is
									  a dot nobody can see.
									*/}
									<input
										class="pbf-pill-input"
										type={group.type === "radio" ? "radio" : "checkbox"}
										id={inputId}
										name={group.name}
										value={option.value}
										required={group.type === "radio" ? group.required : undefined}
									/>
									<span class="pbf-pill-face">{option.label}</span>
								</label>
							);
						})}
					</div>
				</fieldset>
			))}

			<div class="pbf-foot">
				<button class="pbf-submit" type="submit">
					{submitLabel}
					<svg class="pbf-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
						<path d="M5 12h14"></path>
						<path d="m12 5 7 7-7 7"></path>
					</svg>
				</button>

				{/* The note leads into the address, so it renders only with one: no invented contact line. */}
				{mail && (
					<p class="pbf-note">
						{footerText}{" "}
						{mail && <a class="pbf-mail" href={`mailto:${mail}`}>{mail}</a>}
					</p>
				)}
			</div>
		</form>
	</div>
</section>

<style>
	.project-brief-form {
		--pbf-pad: 20px;
		--pbf-gap: 24px;
		--pbf-radius: 9px;
		--pbf-label-size: 13.6px;
		--pbf-input-size: 16px;
		--pbf-control-h: 44px;
		--pbf-pill-pad: 14px 26px;
		--pbf-duration: 150ms;
		--pbf-ease: cubic-bezier(0.4, 0, 0.2, 1);

		box-sizing: border-box;
		display: block;
		inline-size: 100%;
		padding: var(--pbf-pad);
		background: var(--pbf-bg);
		color: var(--pbf-ink);
		/* 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;
	}

	.project-brief-form *,
	.project-brief-form *::before,
	.project-brief-form *::after {
		box-sizing: border-box;
	}

	/*
	 * A scoped reset, and it earns its place. Form controls do not inherit
	 * type from their ancestors, and a page that styles `input`, `label`,
	 * `fieldset` or `button` globally is the normal case rather than the
	 * exception.
	 *
	 * `:where()` keeps this at the specificity of the root alone, so the rules
	 * underneath still win on source order while a host's element selector
	 * loses. The `:global()` is load-bearing: Astro cannot put its scope
	 * attribute inside `:where()`, so without it the compiler emits a second
	 * attribute beside it and the whole thing outranks the class rules.
	 */
	.project-brief-form
		:global(:where(h1, h2, h3, p, a, label, legend, input, textarea, button, fieldset, form, div, span)) {
		margin: 0;
		padding: 0;
		border: 0;
		font: inherit;
		color: inherit;
		letter-spacing: inherit;
		background: none;
	}

	.pbf-panel {
		max-inline-size: var(--pbf-max);
		margin-inline: auto;
		padding: var(--pbf-pad);
		border-radius: var(--pbf-radius);
		background: var(--pbf-panel);
	}

	.pbf-heading {
		margin-block-end: 28px;
		font-size: 28px;
		line-height: 1.15;
		font-weight: 400;
		letter-spacing: -0.01em;
		text-transform: uppercase;
	}

	.pbf-form {
		display: flex;
		flex-direction: column;
		gap: var(--pbf-gap);
	}

	/* ---- text questions --------------------------------------------------- */

	.pbf-row {
		display: flex;
		flex-direction: column;
		gap: var(--pbf-gap);
		/*
		 * The rule under each row, which is what separates one question from
		 * the next on the reference. On the last row before the pills it
		 * doubles as the section divider.
		 */
		padding-block-end: var(--pbf-gap);
		border-block-end: 1px solid var(--pbf-border);
	}

	.pbf-field {
		display: flex;
		flex-direction: column;
		gap: 10px;
		/* So a field in a flex row can shrink instead of forcing the row open. */
		min-inline-size: 0;
		flex: 1 1 0;
	}

	.pbf-label {
		font-size: var(--pbf-label-size);
		line-height: 1.4;
		letter-spacing: 0.0125em;
		/* Uppercased here rather than in the string, so the prop stays readable
		   prose and a screen reader is not handed shouted text. */
		text-transform: uppercase;
		color: var(--pbf-ink);
	}

	.pbf-input {
		inline-size: 100%;
		min-inline-size: 0;
		min-block-size: var(--pbf-control-h);
		padding: 10px 14px;
		border-radius: var(--pbf-radius);
		background: var(--pbf-field);
		color: var(--pbf-ink);
		/*
		 * 16px, and not for looks: anything smaller makes iOS Safari zoom the
		 * page when the field takes focus. The reference sets 13.6px, which
		 * is exactly the size that triggers it.
		 */
		font-size: var(--pbf-input-size);
		line-height: 1.4;
		box-shadow: inset 0 0 0 1px var(--pbf-border);
		transition: box-shadow var(--pbf-duration) var(--pbf-ease);
	}

	.pbf-input::placeholder {
		color: var(--pbf-muted);
		opacity: 1;
	}

	.pbf-textarea {
		/* Vertical only: a horizontal drag can push a textarea past its column
		   and take the page's width with it. */
		resize: vertical;
	}

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

	/* ---- pill groups ------------------------------------------------------ */

	.pbf-group {
		min-inline-size: 0;
		padding-block-end: var(--pbf-gap);
		border-block-end: 1px solid var(--pbf-border);
	}

	.pbf-legend {
		/* A legend positioned normally is what names the group; `display:
		   contents` would drop it from the tree. */
		margin-block-end: 16px;
		font-size: var(--pbf-label-size);
		line-height: 1.4;
		letter-spacing: 0.0125em;
		text-transform: uppercase;
		color: var(--pbf-ink);
	}

	.pbf-pills {
		display: flex;
		flex-wrap: wrap;
		gap: 12px;
	}

	.pbf-pill {
		position: relative;
		display: inline-flex;
		min-inline-size: 0;
	}

	/*
	 * The real control, stretched over the whole pill and made invisible —
	 * not hidden. `opacity: 0` on a full-size input keeps it clickable, keeps
	 * it in the tab order and keeps its label association, and the face beside
	 * it draws the state. The reference leaves its input 13px square in the
	 * corner, so a focus ring would land on a dot nobody can see.
	 */
	.pbf-pill-input {
		position: absolute;
		inset: 0;
		inline-size: 100%;
		block-size: 100%;
		margin: 0;
		opacity: 0;
		cursor: pointer;
	}

	.pbf-pill-face {
		display: inline-flex;
		align-items: center;
		padding: var(--pbf-pill-pad);
		border: 1px solid var(--pbf-border);
		border-radius: 999px;
		background: var(--pbf-bg);
		color: var(--pbf-ink);
		font-size: var(--pbf-label-size);
		line-height: 1.4;
		letter-spacing: 0.0125em;
		text-transform: uppercase;
		text-align: center;
		transition:
			background-color var(--pbf-duration) var(--pbf-ease),
			border-color var(--pbf-duration) var(--pbf-ease),
			color var(--pbf-duration) var(--pbf-ease);
	}

	.pbf-pill-input:hover + .pbf-pill-face {
		border-color: var(--pbf-accent);
	}

	/*
	 * A selected pill looks selected, which the reference's does not. Checking
	 * one there adds a class and changes nothing you can see — background,
	 * border, shadow, opacity, transform, text colour, text weight and both
	 * pseudo-elements all measured identical before and after. A multi-select
	 * with no selected state is a control you cannot read back.
	 */
	.pbf-pill-input:checked + .pbf-pill-face {
		border-color: var(--pbf-accent);
		background: var(--pbf-accent);
		color: var(--pbf-accent-ink);
	}

	.pbf-pill-input:focus-visible + .pbf-pill-face {
		outline: 2px solid var(--pbf-accent);
		outline-offset: 2px;
	}

	/* ---- submit ----------------------------------------------------------- */

	.pbf-foot {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
	}

	.pbf-submit {
		display: inline-flex;
		align-items: center;
		gap: 10px;
		padding: var(--pbf-pill-pad);
		border: 1px solid var(--pbf-border);
		border-radius: 999px;
		background: var(--pbf-bg);
		color: var(--pbf-ink);
		font-size: var(--pbf-label-size);
		line-height: 1.4;
		letter-spacing: 0.0125em;
		text-transform: uppercase;
		cursor: pointer;
		transition:
			background-color var(--pbf-duration) var(--pbf-ease),
			color var(--pbf-duration) var(--pbf-ease);
	}

	.pbf-submit:hover {
		background: var(--pbf-accent);
		color: var(--pbf-accent-ink);
	}

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

	.pbf-arrow {
		flex: 0 0 auto;
		inline-size: 16px;
		block-size: 16px;
	}

	.pbf-note {
		font-size: var(--pbf-label-size);
		line-height: 1.4;
		letter-spacing: 0.0125em;
		text-transform: uppercase;
		color: var(--pbf-muted);
	}

	.pbf-mail {
		color: var(--pbf-ink);
		text-decoration: underline;
		text-underline-offset: 3px;
	}

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

	/* ---- responsive -------------------------------------------------------- */

	/*
	 * Mobile first, so the boundary lands on the same pixel as the
	 * reference's — it pairs its half-width questions above 991px and stacks
	 * them below. Written as `max-width: 991px` the rule would miss a viewport
	 * reporting a fractional width just under the round number, which is a
	 * mistake this library has already made once.
	 */
	@media (min-width: 992px) {
		.project-brief-form {
			--pbf-pad: 48px;
			--pbf-gap: 32px;
			--pbf-label-size: 16px;
			--pbf-pill-pad: 16px 32px;
		}

		.pbf-row--split {
			flex-direction: row;
		}

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

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

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

	@media (forced-colors: active) {
		/*
		 * The field ring is an inset shadow and a shadow is not painted under
		 * a forced palette, so the fields take a real border. The selected
		 * pill is a fill, which is also dropped, so it is marked with the
		 * system Highlight instead.
		 */
		.pbf-input {
			border: 1px solid CanvasText;
			box-shadow: none;
		}

		.pbf-pill-face,
		.pbf-submit {
			border-color: CanvasText;
		}

		.pbf-pill-input:checked + .pbf-pill-face {
			background: Highlight;
			color: HighlightText;
			border-color: Highlight;
		}

		.pbf-submit:hover {
			background: Highlight;
			color: HighlightText;
		}
	}
</style>

===== END src/components/ProjectBriefForm.astro =====

===== FILE (supporting): src/components/ProjectBriefForm.md =====
# Project brief form

A dark intake form built out of questions rather than labels, with
multi-choice answers as pills you select instead of boxes you tick.

Catalog page: `/components/project-brief-form`

## Purpose

The long contact form — the one a studio puts behind "start a project"
rather than "get in touch". It asks enough to qualify the enquiry: who,
where to reply, what the work is, what they need, what it is worth and when.

Use something else for a three-field "drop us a line". This one is a page of
questions, and a page of questions asked of someone who only wants to say
hello is a page they will close.

## Files required

**`ProjectBriefForm.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 and size is a `--pbf-*` custom property
  on the component's own root element.
- No webfonts and **no images**. The arrow is inline SVG, so the component
  makes **no network request of its own**.

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

## Basic usage

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

<ProjectBriefForm action="/api/brief" method="post" email="studio@example.com" />
```

No client directive, no wrapper, no init call — the component ships **no
script at all**. Every control is native, so validation, keyboard behaviour
and submission are the browser's.

## `fields` and `groups` are the whole content API

The component renders whatever you pass, in order: the text questions first,
then the pill groups. That makes it a brief form, a quote request or an
application form depending on the arrays.

```astro
<ProjectBriefForm
  fields={[
    { name: "name", label: "What's your name?", placeholder: "Full name", required: true, span: "half" },
    { name: "email", label: "What's your email?", type: "email", required: true, span: "half" },
    { name: "brief", label: "What's your brief?", type: "textarea", rows: 4, span: "full" },
  ]}
  groups={[
    { name: "services", legend: "What do you need?", type: "checkbox",
      options: ["Strategy", "Identity", "Design", "Build"] },
    { name: "budget", legend: "What is your budget?", type: "radio", required: true,
      options: ["Under £20k", "£20–50k", "£50k+"] },
  ]}
/>
```

Two consecutive `span: "half"` fields share a row above 992px. A `half` with
nothing to pair with gets a row of its own rather than sitting half-width
beside empty space.

### The types

```ts
interface BriefField {
  name: string;
  label: string;                  // the question; the CSS uppercases it
  type?: "text" | "email" | "url" | "tel" | "date" | "textarea";
  placeholder?: string;           // a hint, never a label
  required?: boolean;
  span?: "half" | "full";
  autocomplete?: string;
  rows?: number;                  // textarea only
}

interface BriefChoiceGroup {
  name: string;
  legend: string;
  type?: "checkbox" | "radio";
  options: (string | { label: string; value?: string })[];
  required?: boolean;             // radio groups only
}
```

**Checkbox groups submit their name once per selected pill.** A handler
reading a single value will see only the last one — read them as a list.

`required` on a *checkbox* group is deliberately not supported, because
requiring each box individually makes the form unsubmittable. See the
differences below; the reference does exactly that.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `heading` | `string?` | `""` | Optional heading above the questions. |
| `headingLevel` | `"h1" \| "h2" \| "h3"?` | `"h2"` | So it fits your page outline. |
| `fields` | `BriefField[]?` | five | The text questions. |
| `groups` | `BriefChoiceGroup[]?` | one: "Do you have a deadline?" | The pill groups. A services list and budget bands describe a particular business and its prices, so they are not defaulted — pass your own (the catalog demo passes services, budget and deadline). |
| `action` | `string?` | — | Where the form posts. See below. |
| `method` | `"get" \| "post"?` | `"post"` | |
| `formName` | `string?` | — | Added to the `<form>`. |
| `submitLabel` | `string?` | `"Send the brief"` | The button. |
| `footerText` | `string?` | `"Or write to us directly at"` | Words before the address. The line renders only when `email` is set. |
| `email` | `string?` | none — no contact line | The fallback address. Never defaulted: a real business's address only. |
| `background` | `string?` | `#1d1d1d` | Behind the panel, edge to edge. |
| `panel` | `string?` | `#252525` | The panel itself. |
| `ink` | `string?` | `#e5e4e0` | Questions, answers, the submit. |
| `mutedInk` | `string?` | `#999999` | Placeholders and the footer line. |
| `fieldBackground` | `string?` | `rgb(218 218 218 / 0.2)` | Field fill. |
| `borderColor` | `string?` | `rgb(218 218 218 / 0.2)` | Every hairline. |
| `accent` | `string?` | `#e5e4e0` | A selected pill and the focus ring. |
| `accentInk` | `string?` | `#1d1d1d` | Text on a selected pill. |
| `maxWidth` | `string?` | `1165px` | Where the panel stops widening. |
| `class` | `string?` | — | Extra class on the root. |
| `id` | `string?` | — | Root id. **Also seeds every field id.** |

### A light form

Every tone is a prop, so the light version is eight of them:

```astro
<ProjectBriefForm
  background="#f1f0ec"
  panel="#ffffff"
  ink="#1d1d1d"
  mutedInk="#6f6f6f"
  fieldBackground="rgb(29 29 29 / 0.04)"
  borderColor="rgb(29 29 29 / 0.18)"
  accent="#1d1d1d"
  accentInk="#f1f0ec"
/>
```

There is no `prefers-color-scheme` rule in the file, on purpose: no component
in this library ships one, because a component that picks its own theme
fights the page it lands in.

## It has no back end

**`action` is unset by default**, which means the browser submits to the
current URL. That is fine in a preview and wrong on a site — point it at
something. There is no success state, no error state and no spinner, because
there is no submission handling. This is the form; whatever receives the POST
owns the response.

## Behaviour

Native, all of it.

| Input | What happens |
| :--- | :--- |
| Submit with a required field empty | The browser blocks it and points at the field. No script. |
| Click a pill | Toggles a checkbox, or selects within a radio group. |
| <kbd>Space</kbd> on a focused pill | The same. It is a real checkbox or radio. |
| <kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd> in a radio group | Moves the selection, as radios do. |
| <kbd>Tab</kbd> | Each field, then each checkbox pill, then **one stop per radio group**. |

That last row is the browser's own behaviour and it is correct: a radio group
is one stop, and the arrow keys move within it.

## Accessibility

- **Every field has a real `<label for>`.** The reference asks its questions
  in a `<span>` and gives the inputs no label at all, so each one is named by
  its placeholder — and a placeholder disappears the moment you type.
- Each pill group is a real `<fieldset>` with a real `<legend>`.
- **The pill's input is the size of the pill.** It is a real checkbox or
  radio at `opacity: 0` stretched over the whole control, so it stays
  clickable, stays in the tab order and keeps its label association. The
  reference leaves its input 13px square in a corner, which is where a focus
  ring would land.
- **A selected pill looks selected.** See the differences below.
- Inputs are set at **16px**, and not for looks: anything smaller makes iOS
  Safari zoom the page when the field takes focus. The reference uses 13.6px,
  which is exactly the size that triggers it.
- `autocomplete` is passed through per field.
- Focus rings are 2px of the accent — inside the box on text fields, outside
  it on pills and the button.
- Under `forced-colors: active` the fields take a real border and a selected
  pill is marked with the system `Highlight`, since a shadow and a fill are
  both dropped there.
- The `<section>` has no accessible name, deliberately: the heading is
  optional and is caller copy. Wrap it or pass an `aria-label` on a page with
  several landmarks.

## Reduced motion

Under `prefers-reduced-motion: reduce` the one duration token collapses to
`0.01ms`, so the colour transitions become cuts. Nothing rests at zero
opacity or zero size, so the form is correct rather than blank.

## Without JavaScript

Identical. Every control is native, so there is nothing to degrade.

## Responsive

One step, at 992px — the reference's own breakpoint.

| Width | Paired fields | Panel padding | Question size | Pill padding |
| :--- | :--- | :--- | :--- | :--- |
| ≥ 992 | side by side | 48px | 16px | 16/32 |
| < 992 | stacked | 20px | 13.6px | 14/26 |

## Differences from the reference

Rebuilt from a live page, measured rather than copied. The structure, the
palette and the proportions are its own: a `#252525` panel on `#1d1d1d` at a
9px radius, questions in uppercase at 16px and 0.2px of tracking, fields on a
translucent `rgb(218 218 218 / 0.2)` fill with a matching hairline, and pills
at 16/32 on a 999px radius. What differs:

- **The copy is original.** None of the reference's wording is reproduced.
- **A selected pill is visible.** On the reference, checking one adds a class
  and changes *nothing* you can see — background, border, shadow, opacity,
  transform, text colour, text weight and both pseudo-elements all measure
  identical before and after, in its dark theme. A multi-select you cannot
  read back is not a multi-select. Here a selected pill fills with the ink.
- **The fields have labels.** The reference's have none at all.
- **The pills' inputs are full size**, not 13px in a corner.
- **No checkbox is `required`.** The reference marks every service and every
  source checkbox `required`, which in HTML means each one must be ticked —
  all ten services and all eight sources — or the form will not submit.
- **Inputs are 16px and 44px tall**, against its 13.6px and 40px.
- **The type does not scale with the viewport.** Above its breakpoint the
  reference sizes everything from a viewport-linked root, so at 1440px its
  "16px" question renders at 15.24px and at 1280px at 13.55px. That is a
  page-level decision rather than a component one, so this uses fixed values
  at the design size with one breakpoint.
- The reference sets its type in a licensed face (Ataero). This uses the
  system stack, because a component in this library fetches nothing.

===== END src/components/ProjectBriefForm.md =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
Verified 2026-09-22 at 1600, 1440, 1280, 1100, 992, 991, 900, 834, 768, 767, 640, 480, 390, 360, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source fingerprint: sha256:d842e779533a53805af5d1effefe56daf6b156af4d631b2120708bbb81377fc1 — MATCHES 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
“Project brief form” from my Astro Component Library:
https://astro.baysixmedia.com/components/project-brief-form/

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
2026-09-22
Client navigation
Not applicable — ships no script
Source fingerprint
Matches the verified source
Production ready
Yes

Production-ready component

Every required check passed against the code below, and the recorded fingerprint still matches it.

Verified 2026-09-22 at 1600, 1440, 1280, 1100, 992, 991, 900, 834, 768, 767, 640, 480, 390, 360, 320px

  • Full-page contextpass
  • Keyboardpass
  • No JavaScriptpass
  • Reduced motionpass
  • Two instancespass
  • No overflowpass
  • Declarations corroboratedpass
  • Lifecycle — declared to ship no scriptnot-applicable
28 verification notes
  • Fingerprint re-recorded 2026-09-24 (Step 14, core page safety) after a no-behaviour content change, following the precedent above: the default services and budget groups became empty (only the generic deadline question stays) and moved into the registry demo props. Style and script blocks are byte-identical to the verified revision, and every preview, integration-test and template page was built before and after and compared — this component's pages are identical apart from per-build random ids. The no-data behaviour is pinned by rendering tests in src/lib/content-safety.test.ts (no props, one prop, one nested field). No new full verification run was performed.
  • Fingerprint re-recorded 2026-09-24 (Step 13, content safety) after a no-behaviour content change, following the precedent above: the default contact address studio@example.com was removed (moved into the registry demo props) and the footer note now renders only with an address. Style and script blocks are byte-identical to the verified revision. Evidence for keeping this record: every preview, integration-test and template page was built before and after the change and compared — identical apart from per-build random ids — so each check below still describes what renders with the catalog's configuration. The new no-data behaviour is pinned by rendering tests in src/lib/content-safety.test.ts rather than by a fixture, and was checked in Chromium at 320, 390, 768 and 1440 (no overflow, no console errors, no duplicate ids, nothing rendered). No new full verification run was performed.
  • Every check below was run against the dev server and then re-run against the production build served by `astro preview`. Both agreed.
  • Rebuilt from a live page by measurement, not by copying. Read at 1440px in the reference's dark theme: a #252525 panel on a #1d1d1d ground at a 9px radius with 48px of padding; questions in uppercase at 16px on 1.4 with 0.2px of tracking; fields on a translucent rgb(218 218 218 / .2) fill inside a matching 1px hairline at the same 9px radius; a rule of that hairline under every question row and under every pill group; pills at 16px/32px padding on a 999px radius over the page dark, 12px apart; and the submit drawn as another pill. Measured here: panel 1165 x 48px padding at a 9px radius, fields 44px tall at 10/14 padding, pills 56.4px tall, all matching.
  • The reference sizes everything from a viewport-linked root above its breakpoint, so its own numbers are never round: the 16px question renders at 15.24px at 1440 and at 13.55px at 1280, and every padding and radius moves with them. That is a page-level decision rather than a component one - a library component cannot assume the host's root font-size - so the design values were recovered by dividing out the scale (a consistent 20/21 at 1440) and are emitted as fixed px with one breakpoint. Stated rather than absorbed, because it means a side-by-side at any width except the design size will differ by that factor.
  • One breakpoint, at 992px, which is the reference's own: above it two `half` questions share a row and the panel runs at 48px padding with 16px questions; below it they stack and it runs at 20px with 13.6px. Written mobile-first, so the boundary lands on the same pixel - as `max-width: 991px` it would miss a viewport reporting a fractional width just under the round number, which is a mistake this library has already made once. Verified on both sides: 992 gives a row at 48px and 16px, 991 gives a column at 20px and 13.6px.
  • A selected pill is visible here, and on the reference it is not. Checking one there adds a class and changes nothing that can be seen: background, border colour, box-shadow, opacity, transform, text colour, text weight and both pseudo-elements all measure identical before and after in its dark theme. A multi-select whose selections cannot be read back is not a multi-select. Here a selected pill fills with the ink and inverts its text - measured across a real click: background #1d1d1d to #e5e4e0, border from the 20-per-cent hairline to the ink, text #e5e4e0 to #1d1d1d.
  • Proving that took a corrected method, and the correction is worth recording because the first result was a false negative. Setting `.checked` from script does not invalidate an adjacent-sibling rule in Chromium: the input matches `:checked`, the selector matches the sibling, the rule exists with the right declaration and the custom property resolves - and the sibling still reports the old computed value. Every one of those five things was checked before the cause was found. The check now clicks the control the way a person would, and the state flips.
  • Every field has a real `<label for>` pointing at a real id. The reference asks its questions in a `<span>` and gives its inputs no label at all - no `for`, no wrapping label, no aria-label - so each of its five fields is named by its placeholder, which disappears the moment somebody types. Verified here: zero controls without a label or an aria-label, across both fixture instances.
  • Each pill group is a real `<fieldset>` with a real `<legend>`, and the pill's own control is a real checkbox or radio at `opacity: 0` stretched over the whole pill. The reference leaves its input 13px square in a corner of a 189px control, which is where a focus ring would land - a ring on a dot nobody can see. Full-size here, so the hit area is the pill, the tab order is honest and the focus ring is drawn on the face beside it.
  • No checkbox is `required`, and that is a deliberate refusal to copy. The reference marks every service checkbox and every source checkbox `required`, which in HTML means each one must be ticked individually - all ten services and all eight sources - before the form will submit. Verified here: zero required checkboxes, and `required` is accepted on radio groups only, where it means what people expect.
  • Ids are seeded from the `id` prop when the caller gives one and from a random suffix otherwise, because an index-derived id collides on the second instance - this library has shipped that bug before. Every field id, every pill id and every `for` runs through one sanitiser, since `id` is caller data and lands in id attributes. Verified on the fixture: two instances, zero duplicate ids.
  • Keyboard: the order runs the text questions, then one stop per checkbox pill, then ONE stop per radio group, then the submit and the fallback address, and nothing after them inside the section. That radio behaviour is the browser's and it is correct - a group is a single stop and the arrow keys move within it. The focus ring is 2px of the accent, inside the box on text fields and outside it on pills and the button.
  • Inputs are 16px on a 44px control against the reference's 13.6px on 40px. The size is functional rather than typographic: anything under 16px makes iOS Safari zoom the page when a field takes focus, and 13.6px is squarely inside that. Recorded as a divergence because it is one of the few places the rebuilt form does not match a measured value on purpose.
  • Static, and checked rather than assumed: zero `<script>` elements. With JavaScript disabled the form is unchanged - twenty-one controls, three fieldsets, the submit present, no overflow - because there was never anything for a script to do. Validation, the pill toggles, the radio arrow keys and submission are all the browser's.
  • Lifecycle and client navigation are not-applicable. The component registers no listeners, holds no state and writes nothing to the document, so there is nothing for a router to strand. A pass would imply a teardown was observed working; there is no teardown.
  • Reduced motion: the single duration token collapses to 1e-05s, so the colour transitions on the fields, the pills and the submit all become cuts. Nothing rests at zero opacity or zero size, so the form stays correct rather than going blank.
  • Forced colours: the field ring is an inset shadow and a selected pill is a fill, and neither is painted under a forced palette - without intervention a filled pill would be indistinguishable from an empty one, which is the same defect the reference has by default. The fields take a real border and a selected pill is marked with the system Highlight. Observed under emulation: fields `1px solid` with the shadow reporting `none`, and a checked pill resolving to the highlight pair.
  • Two instances on one page are independent and differ in STATE rather than only in identity, and between them they cover every branch. The first is the dark default: no heading, five questions with two paired rows, three groups mixing checkboxes and radios, and the fallback address. The second runs a light palette through the eight colour props, adds a heading one level down, drops to three unpaired questions and a single required radio group, drops the footer line, and posts GET to a real action. Zero duplicate ids, no page overflow.
  • Declarations corroborated against the rendered fixture: twenty-one focusable controls against hasFocusableContent, measured transition durations against hasMotion, zero script elements against interactivity: static, both supplied ids reaching their roots and seeding their field ids against acceptsIdProp, and two independently-configured instances against multiInstanceSupport.
  • No overflow anywhere. Checked at fifteen widths from 1600 down to 320 on both the preview and the fixture route: no descendant escapes the section box on either side, and `scrollWidth === clientWidth` on the document at every one. The textarea is `resize: vertical` for the same reason - a horizontal drag can otherwise push it past its column and take the page's width with it.
  • `fields` and `groups` are the whole content API, so the component is a brief form, a quote request or an application form depending on the arrays, and the fixture proves it renders a three-question single-group version as readily as the default. Rows are assembled in the frontmatter rather than the template so that a `half` with nothing to pair with takes a row of its own instead of sitting half-width beside empty space.
  • Install kit confirmed from the component's own page: one required file carried inline with its destination path, plus the README as supporting. There are no assets - the only graphic is an inline arrow - so the form renders with nothing copied into `public/` and issues no network request of its own.
  • Zero console messages and zero page errors across the preview route, the fixture route and the component page.
  • The copy is original to this library. None of the reference's wording is reproduced, and its licensed display face is not used - this sets the system stack, because a component in this library fetches nothing.
  • There is no success state, no error state and no submission handling of any kind, and `action` is unset by default, which means the browser posts to the current URL. Stated in the integration notes rather than papered over: this is the form, not the plumbing, and shipping it without pointing `action` somewhere is a live defect in the installation rather than in the component.
  • Checkbox groups submit their name once per selected pill, which a handler reading a single value will silently truncate to the last one. Called out in the integration notes because it is the kind of thing that looks like it works until somebody ticks two boxes.
  • Chromium only. No WebKit or Gecko build was available in this session, and no claim is made about either. The place that gap matters most is the `:checked + sibling` rule that draws the selected pill, since that is the whole of the selected state.

Open the verification fixture

src/components/library/ProjectBriefForm.astro

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

---
/**
 * Project brief form
 *
 * A dark intake form built out of questions: each field is asked rather than
 * labelled, and the multi-choice answers are pills you select instead of
 * boxes you tick. Text fields, then any number of choice groups, then a
 * submit row with a fallback address.
 *
 * Static: no script at all. Every control is a native form element, so
 * validation, keyboard behaviour and submission are the browser's, and the
 * whole thing works with JavaScript switched off.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont.
 * Every colour and measurement is a `--pbf-*` custom property on the root.
 */

export interface BriefField {
	/** Submitted under this name. */
	name: string;
	/** The question. Rendered uppercase by the stylesheet, not the string. */
	label: string;
	/** Native input type, or a textarea. */
	type?: "text" | "email" | "url" | "tel" | "date" | "textarea";
	/** A hint, never a label. */
	placeholder?: string;
	required?: boolean;
	/** `half` lets two fields share a row above the breakpoint. */
	span?: "half" | "full";
	/** Passed straight through, so a browser can fill the field. */
	autocomplete?: string;
	/** Rows for a textarea. Ignored otherwise. */
	rows?: number;
}

export interface BriefChoiceGroup {
	/** Submitted under this name. Checkboxes repeat it; radios share it. */
	name: string;
	/** The question above the pills. */
	legend: string;
	/** Many or one. */
	type?: "checkbox" | "radio";
	/** The pills. A string is its own value. */
	options: (string | { label: string; value?: string })[];
	/** Radio groups only: require an answer. */
	required?: boolean;
}

interface Props {
	/** Optional heading above the questions. `""` drops it. */
	heading?: string;
	/** Heading level, so it fits the page outline. */
	headingLevel?: "h1" | "h2" | "h3";
	/** The text questions, in order. */
	fields?: BriefField[];
	/** The pill groups, in order, after the text questions. Defaults to one generic deadline question. */
	groups?: BriefChoiceGroup[];
	/**
	 * Where the form posts. Left unset the browser submits to the current
	 * URL — fine for a preview, wrong for a site. Point it at a handler.
	 */
	action?: string;
	/** `post` unless you have a reason. */
	method?: "get" | "post";
	/** Added to the <form>, for handlers that route on it. */
	formName?: string;
	/** The submit button. */
	submitLabel?: string;
	/** The words before the fallback address. `""` drops the line. */
	footerText?: string;
	/** The fallback address. `""` leaves the text with no link. */
	email?: string;
	/** Behind the panel, edge to edge. */
	background?: string;
	/** The panel itself. */
	panel?: string;
	/** Questions, answers, the submit pill. */
	ink?: string;
	/** Placeholders and the footer line. */
	mutedInk?: string;
	/** Field fill. Translucent over the panel by default. */
	fieldBackground?: string;
	/** Hairlines: field edges, pill edges, the rules between sections. */
	borderColor?: string;
	/** A selected pill and the focus ring. */
	accent?: string;
	/** Text on a selected pill. */
	accentInk?: string;
	/** Where the panel stops widening. */
	maxWidth?: string;
	/** Added to the root. */
	class?: string;
	/** Root element id. Also seeds every generated id. */
	id?: string;
}

const {
	heading = "",
	headingLevel = "h2",
	fields = [
		{ name: "name", label: "What's your name?", placeholder: "Full name", required: true, span: "half", autocomplete: "name" },
		{ name: "email", label: "What's your email?", type: "email", placeholder: "you@company.com", required: true, span: "half", autocomplete: "email" },
		{ name: "brief", label: "What's your brief?", type: "textarea", placeholder: "Two or three paragraphs is plenty…", required: true, span: "full", rows: 4 },
		{ name: "website", label: "Current website", type: "url", placeholder: "www.example.com", span: "half", autocomplete: "url" },
		{ name: "start", label: "When would you like to start?", placeholder: "DD / MM / YYYY", span: "half" },
	],
	/*
	 * Only the question any brief can ask. A services list and budget bands
	 * describe a particular business and its prices, so they are not assumed:
	 * the demo passes them.
	 */
	groups = [
		{
			name: "deadline",
			legend: "Do you have a deadline?",
			type: "radio",
			options: ["Yes, a fixed one", "No, no rush", "No, but soon"],
		},
	],
	action,
	method = "post",
	formName,
	submitLabel = "Send the brief",
	footerText = "Or write to us directly at",
	/* No default address: an email is a fact about a real business. The demo passes one. */
	email = "",
	background = "#1d1d1d",
	panel = "#252525",
	ink = "#e5e4e0",
	mutedInk = "#999999",
	fieldBackground = "rgb(218 218 218 / 0.2)",
	borderColor = "rgb(218 218 218 / 0.2)",
	accent = "#e5e4e0",
	accentInk = "#1d1d1d",
	maxWidth = "1165px",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

/*
 * Every control needs an id its label can point at, and two of these on one
 * page must not collide. Derived from `id` when the caller gives one, random
 * otherwise — an index-derived id would collide on the second instance.
 */
const uid = id ?? `pbf-${Math.random().toString(36).slice(2, 9)}`;
/* `uid` can arrive from the `id` prop, which is caller data, and it goes into
   id and `for` attributes; anything that is not an identifier character is
   stripped before it gets there. */
const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, "") || "pbf";
const fid = (name: string) => `${safeUid}-${name.replace(/[^A-Za-z0-9_-]/g, "") || "f"}`;

const textFields = (fields ?? []).filter((f) => f?.name && f?.label);
const choiceGroups = (groups ?? []).filter((g) => g?.name && g?.legend && g.options?.length);

/*
 * Rows are built here rather than in the template, so a `half` with nothing
 * to pair with still gets a row of its own instead of a half-width field
 * stranded beside empty space.
 */
const rows: BriefField[][] = [];
for (const field of textFields) {
	const last = rows[rows.length - 1];
	if (field.span === "half" && last && last.length === 1 && last[0].span === "half") {
		last.push(field);
	} else {
		rows.push([field]);
	}
}

const optionOf = (o: string | { label: string; value?: string }) =>
	typeof o === "string" ? { label: o, value: o } : { label: o.label, value: o.value ?? o.label };

const mail = email?.trim();

const style = [
	`--pbf-bg:${background}`,
	`--pbf-panel:${panel}`,
	`--pbf-ink:${ink}`,
	`--pbf-muted:${mutedInk}`,
	`--pbf-field:${fieldBackground}`,
	`--pbf-border:${borderColor}`,
	`--pbf-accent:${accent}`,
	`--pbf-accent-ink:${accentInk}`,
	`--pbf-max:${maxWidth}`,
].join(";");
---

<section class:list={["project-brief-form", className]} id={id} style={style} data-project-brief-form>
	<div class="pbf-panel">
		{heading && <Heading class="pbf-heading">{heading}</Heading>}

		<form class="pbf-form" action={action} method={method} name={formName}>
			{rows.map((row) => (
				<div class:list={["pbf-row", row.length > 1 && "pbf-row--split"]}>
					{row.map((field) => (
						<div class="pbf-field">
							{/*
							  A real label with a real `for`. The reference asks the
							  question in a <span> and gives the input no label at
							  all, so every field is named by its placeholder alone —
							  and a placeholder disappears the moment you type.
							*/}
							<label class="pbf-label" for={fid(field.name)}>{field.label}</label>

							{field.type === "textarea" ? (
								<textarea
									class="pbf-input pbf-textarea"
									id={fid(field.name)}
									name={field.name}
									rows={field.rows ?? 4}
									placeholder={field.placeholder}
									required={field.required}
									autocomplete={field.autocomplete}></textarea>
							) : (
								<input
									class="pbf-input"
									type={field.type ?? "text"}
									id={fid(field.name)}
									name={field.name}
									placeholder={field.placeholder}
									required={field.required}
									autocomplete={field.autocomplete}
								/>
							)}
						</div>
					))}
				</div>
			))}

			{choiceGroups.map((group) => (
				/* A real fieldset with a real legend, which is what groups a set
				   of related controls for assistive technology. */
				<fieldset class="pbf-group">
					<legend class="pbf-legend">{group.legend}</legend>
					<div class="pbf-pills">
						{group.options.map((raw, i) => {
							const option = optionOf(raw);
							const inputId = fid(`${group.name}-${i}`);
							return (
								<label class="pbf-pill" for={inputId}>
									{/*
									  The real control, stretched over the whole pill
									  rather than parked in a corner. The reference
									  leaves its input 13px square and invisible, so
									  the only thing a keyboard ring could land on is
									  a dot nobody can see.
									*/}
									<input
										class="pbf-pill-input"
										type={group.type === "radio" ? "radio" : "checkbox"}
										id={inputId}
										name={group.name}
										value={option.value}
										required={group.type === "radio" ? group.required : undefined}
									/>
									<span class="pbf-pill-face">{option.label}</span>
								</label>
							);
						})}
					</div>
				</fieldset>
			))}

			<div class="pbf-foot">
				<button class="pbf-submit" type="submit">
					{submitLabel}
					<svg class="pbf-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
						<path d="M5 12h14"></path>
						<path d="m12 5 7 7-7 7"></path>
					</svg>
				</button>

				{/* The note leads into the address, so it renders only with one: no invented contact line. */}
				{mail && (
					<p class="pbf-note">
						{footerText}{" "}
						{mail && <a class="pbf-mail" href={`mailto:${mail}`}>{mail}</a>}
					</p>
				)}
			</div>
		</form>
	</div>
</section>

<style>
	.project-brief-form {
		--pbf-pad: 20px;
		--pbf-gap: 24px;
		--pbf-radius: 9px;
		--pbf-label-size: 13.6px;
		--pbf-input-size: 16px;
		--pbf-control-h: 44px;
		--pbf-pill-pad: 14px 26px;
		--pbf-duration: 150ms;
		--pbf-ease: cubic-bezier(0.4, 0, 0.2, 1);

		box-sizing: border-box;
		display: block;
		inline-size: 100%;
		padding: var(--pbf-pad);
		background: var(--pbf-bg);
		color: var(--pbf-ink);
		/* 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;
	}

	.project-brief-form *,
	.project-brief-form *::before,
	.project-brief-form *::after {
		box-sizing: border-box;
	}

	/*
	 * A scoped reset, and it earns its place. Form controls do not inherit
	 * type from their ancestors, and a page that styles `input`, `label`,
	 * `fieldset` or `button` globally is the normal case rather than the
	 * exception.
	 *
	 * `:where()` keeps this at the specificity of the root alone, so the rules
	 * underneath still win on source order while a host's element selector
	 * loses. The `:global()` is load-bearing: Astro cannot put its scope
	 * attribute inside `:where()`, so without it the compiler emits a second
	 * attribute beside it and the whole thing outranks the class rules.
	 */
	.project-brief-form
		:global(:where(h1, h2, h3, p, a, label, legend, input, textarea, button, fieldset, form, div, span)) {
		margin: 0;
		padding: 0;
		border: 0;
		font: inherit;
		color: inherit;
		letter-spacing: inherit;
		background: none;
	}

	.pbf-panel {
		max-inline-size: var(--pbf-max);
		margin-inline: auto;
		padding: var(--pbf-pad);
		border-radius: var(--pbf-radius);
		background: var(--pbf-panel);
	}

	.pbf-heading {
		margin-block-end: 28px;
		font-size: 28px;
		line-height: 1.15;
		font-weight: 400;
		letter-spacing: -0.01em;
		text-transform: uppercase;
	}

	.pbf-form {
		display: flex;
		flex-direction: column;
		gap: var(--pbf-gap);
	}

	/* ---- text questions --------------------------------------------------- */

	.pbf-row {
		display: flex;
		flex-direction: column;
		gap: var(--pbf-gap);
		/*
		 * The rule under each row, which is what separates one question from
		 * the next on the reference. On the last row before the pills it
		 * doubles as the section divider.
		 */
		padding-block-end: var(--pbf-gap);
		border-block-end: 1px solid var(--pbf-border);
	}

	.pbf-field {
		display: flex;
		flex-direction: column;
		gap: 10px;
		/* So a field in a flex row can shrink instead of forcing the row open. */
		min-inline-size: 0;
		flex: 1 1 0;
	}

	.pbf-label {
		font-size: var(--pbf-label-size);
		line-height: 1.4;
		letter-spacing: 0.0125em;
		/* Uppercased here rather than in the string, so the prop stays readable
		   prose and a screen reader is not handed shouted text. */
		text-transform: uppercase;
		color: var(--pbf-ink);
	}

	.pbf-input {
		inline-size: 100%;
		min-inline-size: 0;
		min-block-size: var(--pbf-control-h);
		padding: 10px 14px;
		border-radius: var(--pbf-radius);
		background: var(--pbf-field);
		color: var(--pbf-ink);
		/*
		 * 16px, and not for looks: anything smaller makes iOS Safari zoom the
		 * page when the field takes focus. The reference sets 13.6px, which
		 * is exactly the size that triggers it.
		 */
		font-size: var(--pbf-input-size);
		line-height: 1.4;
		box-shadow: inset 0 0 0 1px var(--pbf-border);
		transition: box-shadow var(--pbf-duration) var(--pbf-ease);
	}

	.pbf-input::placeholder {
		color: var(--pbf-muted);
		opacity: 1;
	}

	.pbf-textarea {
		/* Vertical only: a horizontal drag can push a textarea past its column
		   and take the page's width with it. */
		resize: vertical;
	}

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

	/* ---- pill groups ------------------------------------------------------ */

	.pbf-group {
		min-inline-size: 0;
		padding-block-end: var(--pbf-gap);
		border-block-end: 1px solid var(--pbf-border);
	}

	.pbf-legend {
		/* A legend positioned normally is what names the group; `display:
		   contents` would drop it from the tree. */
		margin-block-end: 16px;
		font-size: var(--pbf-label-size);
		line-height: 1.4;
		letter-spacing: 0.0125em;
		text-transform: uppercase;
		color: var(--pbf-ink);
	}

	.pbf-pills {
		display: flex;
		flex-wrap: wrap;
		gap: 12px;
	}

	.pbf-pill {
		position: relative;
		display: inline-flex;
		min-inline-size: 0;
	}

	/*
	 * The real control, stretched over the whole pill and made invisible —
	 * not hidden. `opacity: 0` on a full-size input keeps it clickable, keeps
	 * it in the tab order and keeps its label association, and the face beside
	 * it draws the state. The reference leaves its input 13px square in the
	 * corner, so a focus ring would land on a dot nobody can see.
	 */
	.pbf-pill-input {
		position: absolute;
		inset: 0;
		inline-size: 100%;
		block-size: 100%;
		margin: 0;
		opacity: 0;
		cursor: pointer;
	}

	.pbf-pill-face {
		display: inline-flex;
		align-items: center;
		padding: var(--pbf-pill-pad);
		border: 1px solid var(--pbf-border);
		border-radius: 999px;
		background: var(--pbf-bg);
		color: var(--pbf-ink);
		font-size: var(--pbf-label-size);
		line-height: 1.4;
		letter-spacing: 0.0125em;
		text-transform: uppercase;
		text-align: center;
		transition:
			background-color var(--pbf-duration) var(--pbf-ease),
			border-color var(--pbf-duration) var(--pbf-ease),
			color var(--pbf-duration) var(--pbf-ease);
	}

	.pbf-pill-input:hover + .pbf-pill-face {
		border-color: var(--pbf-accent);
	}

	/*
	 * A selected pill looks selected, which the reference's does not. Checking
	 * one there adds a class and changes nothing you can see — background,
	 * border, shadow, opacity, transform, text colour, text weight and both
	 * pseudo-elements all measured identical before and after. A multi-select
	 * with no selected state is a control you cannot read back.
	 */
	.pbf-pill-input:checked + .pbf-pill-face {
		border-color: var(--pbf-accent);
		background: var(--pbf-accent);
		color: var(--pbf-accent-ink);
	}

	.pbf-pill-input:focus-visible + .pbf-pill-face {
		outline: 2px solid var(--pbf-accent);
		outline-offset: 2px;
	}

	/* ---- submit ----------------------------------------------------------- */

	.pbf-foot {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
	}

	.pbf-submit {
		display: inline-flex;
		align-items: center;
		gap: 10px;
		padding: var(--pbf-pill-pad);
		border: 1px solid var(--pbf-border);
		border-radius: 999px;
		background: var(--pbf-bg);
		color: var(--pbf-ink);
		font-size: var(--pbf-label-size);
		line-height: 1.4;
		letter-spacing: 0.0125em;
		text-transform: uppercase;
		cursor: pointer;
		transition:
			background-color var(--pbf-duration) var(--pbf-ease),
			color var(--pbf-duration) var(--pbf-ease);
	}

	.pbf-submit:hover {
		background: var(--pbf-accent);
		color: var(--pbf-accent-ink);
	}

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

	.pbf-arrow {
		flex: 0 0 auto;
		inline-size: 16px;
		block-size: 16px;
	}

	.pbf-note {
		font-size: var(--pbf-label-size);
		line-height: 1.4;
		letter-spacing: 0.0125em;
		text-transform: uppercase;
		color: var(--pbf-muted);
	}

	.pbf-mail {
		color: var(--pbf-ink);
		text-decoration: underline;
		text-underline-offset: 3px;
	}

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

	/* ---- responsive -------------------------------------------------------- */

	/*
	 * Mobile first, so the boundary lands on the same pixel as the
	 * reference's — it pairs its half-width questions above 991px and stacks
	 * them below. Written as `max-width: 991px` the rule would miss a viewport
	 * reporting a fractional width just under the round number, which is a
	 * mistake this library has already made once.
	 */
	@media (min-width: 992px) {
		.project-brief-form {
			--pbf-pad: 48px;
			--pbf-gap: 32px;
			--pbf-label-size: 16px;
			--pbf-pill-pad: 16px 32px;
		}

		.pbf-row--split {
			flex-direction: row;
		}

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

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

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

	@media (forced-colors: active) {
		/*
		 * The field ring is an inset shadow and a shadow is not painted under
		 * a forced palette, so the fields take a real border. The selected
		 * pill is a fill, which is also dropped, so it is marked with the
		 * system Highlight instead.
		 */
		.pbf-input {
			border: 1px solid CanvasText;
			box-shadow: none;
		}

		.pbf-pill-face,
		.pbf-submit {
			border-color: CanvasText;
		}

		.pbf-pill-input:checked + .pbf-pill-face {
			background: Highlight;
			color: HighlightText;
			border-color: Highlight;
		}

		.pbf-submit:hover {
			background: Highlight;
			color: HighlightText;
		}
	}
</style>