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.