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: "Quote request panel"
Library page: https://astro.baysixmedia.com/components/quote-request-panel/
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/QuoteRequestPanel.astro =====
---
/**
* Quote request panel
*
* A rounded navy panel with an optional photograph behind it: an eyebrow, a
* display heading and a glass strip of phone and email on the left, and a
* white form card on the right asking for everything a freight quote needs.
*
* Nothing about the business is assumed: no heading, phone, email or photo is
* shown unless it is passed. With none of them the form still renders under
* the generic eyebrow.
*
* No script. Every control is native, so validation, keyboard behaviour and
* submission are the browser's. Colours and fonts read `--lx-*` custom
* properties with fallbacks, so a page can re-theme it from one ancestor.
*/
export interface QuoteLabels {
name?: string;
email?: string;
phone?: string;
company?: string;
pickup?: string;
delivery?: string;
service?: string;
details?: string;
}
export type QuotePlaceholders = QuoteLabels;
export interface QuotePhoto {
src?: string;
/** Empty marks it decorative, which a background photo usually is. */
alt?: string;
/** What the photo should show. Printed on the placeholder while `src` is unset. */
hint?: string;
}
/* The slot's shape, from the reference: a 1200×828 panel, i.e. about 3:2. */
const PHOTO_SPEC = "~3:2 · 2400 × 1660 px";
interface Props {
/** Small pill above the heading. `""` hides it. */
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
/** A real phone number, as displayed. The `tel:` link keeps a leading `+` and the digits; no digits, no link. Unset hides it. */
phone?: string;
/** A real email address. Unset hides it. */
email?: string;
/** Where the form posts. Unset by default: the browser then submits to the current URL. */
action?: string;
method?: "get" | "post";
formName?: string;
submitLabel?: string;
labels?: QuoteLabels;
placeholders?: QuotePlaceholders;
/** The freight-type options, submitted as written. */
serviceOptions?: string[];
/** Placeholder option text for the select. */
servicePrompt?: string;
/**
* The photo behind the panel. With no `src` a labelled placeholder fills
* the slot; `false` (the default) leaves a plain navy panel.
*/
photo?: QuotePhoto | false;
/** The hairline above the panel that separates it from the section before. */
divider?: boolean;
/** Google Fonts stylesheet for Bebas Neue + Poppins. `""` if the site loads them already. */
fontStylesheet?: string;
/** Root id. Also seeds every field id. */
id?: string;
}
const {
eyebrow = "Request a quote",
heading = "",
headingLevel = "h2",
phone = "",
email = "",
action,
method = "post",
formName,
submitLabel = "Send my quote request",
labels: labelOverrides = {},
placeholders: placeholderOverrides = {},
/* No default services: which services are offered is a fact about the business. The demo passes them. */
serviceOptions = [],
servicePrompt = "Choose a service…",
photo = false,
divider = true,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
id,
} = Astro.props;
const labels: Required<QuoteLabels> = {
name: "Full name",
email: "Work email",
phone: "Phone",
company: "Company",
pickup: "Pickup city",
delivery: "Delivery city",
service: "Freight type",
details: "Load details",
...labelOverrides,
};
const placeholders: Required<QuotePlaceholders> = {
name: "Jordan Alvarez",
email: "jordan@company.com",
phone: "(916) 555-0100",
company: "Sierra Outfitters",
pickup: "Sacramento, CA",
delivery: "Denver, CO",
service: "",
details: "Pallet count, weight, ready date…",
...placeholderOverrides,
};
/*
* One id prefix per instance. Two panels on a page must not share field ids,
* or a label in the second would focus a field in the first — so it is never
* derived from position, only from the caller's id or a random suffix.
*/
const uid = id ?? `qrp-${Math.random().toString(36).slice(2, 9)}`;
const fid = (key: string) => `${uid}-${key}`;
const Heading = headingLevel;
const photoHint = (photo && photo.hint) || "Background photo";
const showPhoto = photo !== false;
/* A dialable href from a displayed number: keep a leading + and the digits. */
const telHref = (number: string) => {
const trimmed = number.trim();
const digits = trimmed.replace(/\D/g, "");
return digits ? `tel:${trimmed.startsWith("+") ? "+" : ""}${digits}` : "";
};
/* A call link only with somewhere to dial: words in `phone` are not a number. */
const callHref = phone ? telHref(phone) : "";
type InputField = {
key: keyof QuoteLabels;
type: "text" | "email" | "tel";
autocomplete?: string;
required: boolean;
};
const inputs: InputField[] = [
{ key: "name", type: "text", autocomplete: "name", required: true },
{ key: "email", type: "email", autocomplete: "email", required: true },
{ key: "phone", type: "tel", autocomplete: "tel", required: true },
{ key: "company", type: "text", autocomplete: "organization", required: true },
{ key: "pickup", type: "text", required: true },
{ key: "delivery", type: "text", required: true },
];
---
<section class="qrp" id={id} data-divider={divider ? "" : undefined}>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="qrp-container">
<div class="qrp-panel" data-has-image={showPhoto ? "" : undefined}>
{photo && photo.src && (
<img class="qrp-bg" src={photo.src} alt={photo.alt ?? ""} loading="lazy" decoding="async" />
)}
{photo && !photo.src && (
<>
<div class="qrp-bg qrp-ph" role="img" aria-label={`Image placeholder: ${photoHint}`}></div>
{/* A sibling, not a child, so it can sit above the scrim; it lands
in the open space of the intro column on desktop. */}
<div class="qrp-ph-text" aria-hidden="true">
<span class="qrp-ph-label">Image</span>
<span class="qrp-ph-hint">{photoHint}</span>
<span class="qrp-ph-spec">{PHOTO_SPEC}</span>
</div>
</>
)}
<div class="qrp-grid">
<div class="qrp-intro">
{(eyebrow || heading) && (
<div class="qrp-top">
{eyebrow && <p class="qrp-eyebrow">{eyebrow}</p>}
{heading && <Heading class="qrp-heading" id={fid("heading")}>{heading}</Heading>}
</div>
)}
{(phone || email) && (
<div class="qrp-contact">
{phone && (
<a class="qrp-phone" href={callHref || undefined}>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M6.6 10.8a15.2 15.2 0 0 0 6.6 6.6l2.2-2.2a1 1 0 0 1 1-.25 11.4 11.4 0 0 0 3.6.57 1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57a1 1 0 0 1-.25 1z"
/>
</svg>
<span>{phone}</span>
</a>
)}
{email && (
<a class="qrp-email" href={`mailto:${email}`}>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linejoin="round"
d="M3 5.5h18v13H3zM3.5 6l8.5 7 8.5-7"
/>
</svg>
<span>{email}</span>
</a>
)}
</div>
)}
</div>
<form
class="qrp-form"
action={action}
method={method}
name={formName}
aria-labelledby={heading ? fid("heading") : undefined}
>
{inputs.map((field) => (
<div class="qrp-field">
<label class="qrp-label" for={fid(field.key)}>
{labels[field.key]}
{field.required && <span class="qrp-req" aria-hidden="true"> *</span>}
</label>
<input
class="qrp-control"
id={fid(field.key)}
name={field.key}
type={field.type}
autocomplete={field.autocomplete}
placeholder={placeholders[field.key] || undefined}
required={field.required}
/>
</div>
))}
{/* The service select only exists with real services to choose from. */}
{serviceOptions.filter(Boolean).length > 0 && (
<div class="qrp-field qrp-wide">
<label class="qrp-label" for={fid("service")}>
{labels.service}
<span class="qrp-req" aria-hidden="true"> *</span>
</label>
<div class="qrp-select-wrap">
<select class="qrp-control qrp-select" id={fid("service")} name="service" required>
{/* A placeholder label option: first, value "", so `required` rejects it. */}
<option value="" selected>{servicePrompt}</option>
{serviceOptions.filter(Boolean).map((option) => <option value={option}>{option}</option>)}
</select>
<svg class="qrp-caret" viewBox="0 0 16 10" width="16" height="10" aria-hidden="true" focusable="false">
<path d="M0 0h16L8 10z" fill="currentColor" />
</svg>
</div>
</div>
)}
<div class="qrp-field qrp-wide">
<label class="qrp-label" for={fid("details")}>{labels.details}</label>
<textarea
class="qrp-control qrp-textarea"
id={fid("details")}
name="details"
rows="4"
placeholder={placeholders.details || undefined}
></textarea>
</div>
<button class="qrp-submit qrp-wide" type="submit">
<span>{submitLabel}</span>
<span class="qrp-submit-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="24" height="24" focusable="false">
<path
d="M5 12h14M13 6l6 6-6 6"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</button>
</form>
</div>
</div>
</div>
</section>
<style>
.qrp {
--qrp-navy: var(--lx-navy, #0a2540);
--qrp-accent: var(--lx-accent, #ff5f00);
--qrp-ink-soft: var(--lx-ink-soft, #555555);
--qrp-hairline: rgba(10, 37, 64, 0.14);
--qrp-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
--qrp-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
--qrp-duration: 200ms;
box-sizing: border-box;
padding: 0 20px 60px;
background: #ffffff;
font-family: var(--qrp-body);
color: var(--qrp-navy);
}
.qrp *,
.qrp *::before,
.qrp *::after {
box-sizing: border-box;
}
.qrp-container {
/* Every size inside keys off this width, not the viewport, so the panel
reads the same in a narrow column as it does full-bleed. */
container-type: inline-size;
max-width: 1200px;
margin-inline: auto;
padding-top: 60px;
}
.qrp[data-divider] .qrp-container {
border-top: 1px solid var(--qrp-hairline);
}
/* ---- the panel ---------------------------------------------------- */
.qrp-panel {
position: relative;
isolation: isolate;
overflow: clip;
border-radius: 10px;
background: var(--qrp-navy);
padding: 24px;
}
.qrp-bg {
position: absolute;
inset: 0;
z-index: -2;
width: 100%;
height: 100%;
object-fit: cover;
object-position: left center;
}
/* ---- the photo placeholder ---------------------------------------- */
.qrp-ph {
border: 1px dashed rgba(255, 255, 255, 0.25);
border-radius: 10px;
background: repeating-linear-gradient(
135deg,
rgba(255, 255, 255, 0.06) 0 12px,
rgba(255, 255, 255, 0.1) 12px 24px
);
}
/* Stacked, the content covers the whole panel, so only the stripes show;
the hint is still in the placeholder's accessible name. */
.qrp-ph-text {
display: none;
}
.qrp-ph-label,
.qrp-ph-hint,
.qrp-ph-spec {
display: block;
font-family: var(--qrp-body);
color: rgba(255, 255, 255, 0.72);
}
.qrp-ph-label {
font-size: 12px;
font-weight: 500;
line-height: 1.2;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.qrp-ph-hint {
max-width: 28ch;
margin: 8px auto;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
}
.qrp-ph-spec {
font-size: 12px;
font-weight: 400;
line-height: 1.2;
}
/* The scrim that keeps white type legible over any photo. */
.qrp-panel[data-has-image]::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: rgba(4, 16, 28, 0.4);
}
.qrp-grid {
display: flex;
flex-direction: column;
gap: 32px;
}
/* ---- the intro column --------------------------------------------- */
.qrp-intro {
display: flex;
flex-direction: column;
gap: 20px;
}
.qrp-top {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 20px;
}
.qrp-eyebrow {
margin: 0;
padding: 8px 16px;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 100px;
font-size: 14px;
font-weight: 500;
line-height: 14px;
text-transform: uppercase;
color: #ffffff;
}
.qrp-heading {
margin: 0;
max-width: 420px;
font-family: var(--qrp-display);
font-weight: 400;
/* 30px on a phone, 48px at the full 1200 — the two measured sizes. */
font-size: clamp(30px, 4cqi, 48px);
line-height: 1;
letter-spacing: -0.02em;
color: #ffffff;
}
.qrp-contact {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 20px;
padding: 16px;
border-radius: 13px;
background: rgba(255, 255, 255, 0.14);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
.qrp-phone,
.qrp-email {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
font-size: 14px;
font-weight: 500;
line-height: 14px;
text-transform: uppercase;
transition:
background-color var(--qrp-duration) ease,
color var(--qrp-duration) ease;
}
.qrp-phone {
padding: 0 16px;
border-radius: 100px;
background: #ffffff;
color: var(--qrp-ink-soft);
text-decoration: none;
/* The number stays on one line; the email moves under it instead. */
white-space: nowrap;
}
.qrp-phone svg {
color: var(--qrp-accent);
}
.qrp-phone:hover {
color: var(--qrp-navy);
}
.qrp-email {
color: #ffffff;
text-decoration: underline;
text-underline-offset: 3px;
/* A long address breaks rather than pushing the panel sideways. */
overflow-wrap: anywhere;
}
.qrp-email:hover {
text-decoration-thickness: 2px;
}
.qrp-phone:focus-visible,
.qrp-email:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 3px;
}
/* ---- the form card ------------------------------------------------ */
.qrp-form {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 24px 20px;
padding: 20px;
border-radius: 10px;
background: #ffffff;
}
.qrp-field {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.qrp-wide {
grid-column: 1 / -1;
}
.qrp-label {
font-size: 14px;
font-weight: 500;
line-height: 14px;
text-transform: uppercase;
color: var(--qrp-navy);
}
.qrp-control {
display: block;
width: 100%;
min-height: 50px;
margin: 0;
padding: 12px 14px;
border: 1px solid var(--qrp-hairline);
border-radius: 10px;
background: #ffffff;
font-family: inherit;
/* 16px, not for looks: anything smaller zooms iOS Safari on focus. */
font-size: 16px;
line-height: 1.6;
color: var(--qrp-navy);
transition:
border-color var(--qrp-duration) ease,
outline-color var(--qrp-duration) ease;
}
.qrp-control::placeholder {
color: var(--qrp-ink-soft);
opacity: 0.85;
}
.qrp-control:hover {
border-color: rgba(10, 37, 64, 0.32);
}
/* An outline, not a shadow: a forced palette drops shadows, and the field
would be left with no focus indicator at all. */
.qrp-control:focus {
outline: 2px solid var(--qrp-accent);
outline-offset: 1px;
border-color: var(--qrp-accent);
}
.qrp-textarea {
min-height: 100px;
resize: vertical;
}
.qrp-select-wrap {
position: relative;
}
.qrp-select {
appearance: none;
-webkit-appearance: none;
padding-right: 44px;
cursor: pointer;
}
/* The prompt reads as a placeholder until a real choice is made. */
.qrp-select:has(option[value=""]:checked) {
color: var(--qrp-ink-soft);
}
.qrp-caret {
position: absolute;
top: 50%;
right: 16px;
translate: 0 -50%;
color: var(--qrp-navy);
pointer-events: none;
}
.qrp-submit {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 50px;
margin: 0;
padding: 5px 5px 5px 19px;
border: 0;
border-radius: 50px;
background: var(--qrp-accent);
font-family: inherit;
font-size: 14px;
font-weight: 600;
line-height: 1.2;
text-align: left;
color: #ffffff;
cursor: pointer;
transition: background-color var(--qrp-duration) ease;
}
.qrp-submit:hover {
background: color-mix(in srgb, var(--qrp-accent) 88%, #000000);
}
.qrp-submit:focus-visible {
outline: 2px solid var(--qrp-navy);
outline-offset: 3px;
}
.qrp-submit-icon {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: #ffffff;
color: var(--qrp-accent);
}
.qrp-submit-icon svg {
transition: translate var(--qrp-duration) ease;
}
.qrp-submit:hover .qrp-submit-icon svg {
translate: 2px 0;
}
/* ---- wider containers --------------------------------------------- */
/* Room for two fields side by side: tablets and up, and phones in landscape. */
@container (min-width: 560px) {
.qrp-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 32px;
}
}
/* The measured desktop split: 466 intro + 60 gap + 594 form inside 40px padding. */
@container (min-width: 900px) {
.qrp-panel {
padding: 40px;
}
.qrp-grid {
display: grid;
grid-template-columns: minmax(0, 466fr) minmax(0, 594fr);
gap: 60px;
}
.qrp-intro {
justify-content: space-between;
padding: 20px;
}
/* Centred in the intro column's open middle, between the heading and
the contact strip — the one place nothing covers the photo. */
.qrp-ph-text {
position: absolute;
z-index: -1;
top: 50%;
left: 40px;
display: block;
width: calc((100% - 140px) * 466 / 1060);
padding: 16px;
translate: 0 -50%;
text-align: center;
}
.qrp-contact {
align-self: center;
justify-content: center;
padding: 25px 29px 25px 28px;
border-radius: 20px;
}
.qrp-form {
gap: 30px 20px;
padding: 40px;
}
.qrp-textarea {
min-height: 120px;
}
}
@media (min-width: 810px) {
.qrp {
padding: 0 30px 120px;
}
.qrp-container {
padding-top: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.qrp {
--qrp-duration: 0.01ms;
}
}
@media (forced-colors: active) {
.qrp-control,
.qrp-submit,
.qrp-phone,
.qrp-form,
.qrp-contact {
border: 1px solid CanvasText;
}
.qrp-caret {
color: CanvasText;
}
}
</style>
===== END src/components/QuoteRequestPanel.astro =====
===== FILE (supporting): src/components/QuoteRequestPanel.md =====
# Quote request panel
A rounded navy panel with a photo behind it: eyebrow, display heading and a
glass strip of phone and email on the left; a white form card on the right
with everything a freight quote needs and a full-width orange submit.
Nothing about the business is assumed. No heading, phone, email or photo
renders unless you pass it; with no props you get the generic "Request a
quote" eyebrow and the form on a plain navy panel. The catalog demo passes its
heading, contact details and photo hint as props.
Catalog page: `/components/quote-request-panel`
## Purpose
The "request a quote" block on a logistics, trades or services page. Eight
fields — name, email, phone, company, pickup, delivery, freight type, load
details — laid out two-up on desktop and stacked on a phone.
Use something else if you need conditional fields, multiple steps or file
upload. This is one screen of native inputs.
## Files required
**`QuoteRequestPanel.astro`** — one file, the whole component.
- No imports, no npm packages, no icon library, no framework.
- **No script at all.** Validation, keyboard behaviour and submission are the
browser's.
- Fonts: renders a `<link>` to Google Fonts (Bebas Neue + Poppins) unless you
pass `fontStylesheet=""`.
- **No image files.** No photo by default (a plain navy panel); `photo={{}}`
shows a labelled placeholder until you pass `photo.src`.
`QuoteRequestPanel.md` — this file. Documentation, not required to run.
## Basic usage
```astro
---
import QuoteRequestPanel from "../components/QuoteRequestPanel.astro";
---
<QuoteRequestPanel
id="quote"
action="/api/quote"
photo={{ src: "/img/truck-highway.jpg" }}
/>
```
It is a page section: it applies its own padding (120px bottom and top above
810px viewport width, 60px below) and centres a 1200px container.
## 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; whatever
receives the POST owns the response.
Field names as submitted: `name`, `email`, `phone`, `company`, `pickup`,
`delivery`, `service`, `details`.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `"Request a quote"` | Pill above the heading. `""` hides it. |
| `heading` | `string?` | none | The panel heading. Also names the form (`aria-labelledby`); unset, no heading and the form is unnamed. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | |
| `phone` | `string?` | none | A real number, shown as written; the `tel:` link keeps a leading `+` and the digits. No digits, no link. Unset hides it. |
| `email` | `string?` | none | A real address; `mailto:` only from it. Unset hides it. Neither set removes the strip. |
| `action` | `string?` | — | Where the form posts. See above. |
| `method` | `"get" \| "post"?` | `"post"` | |
| `formName` | `string?` | — | Added to the `<form>`. |
| `submitLabel` | `string?` | `"Send my quote request"` | |
| `labels` | `QuoteLabels?` | English | Visible labels, merged over the defaults. |
| `placeholders` | `QuotePlaceholders?` | English | Hints, never labels. The defaults are obviously sample input hints (`Jordan Alvarez`, `jordan@company.com`, …) shown only inside empty fields — override them for a real site. |
| `serviceOptions` | `string[]?` | none — no service field | The services the business offers, submitted as written. Omitted: the service select is not rendered (the catalog demo passes six). |
| `servicePrompt` | `string?` | `"Choose a service…"` | The select's empty first option. |
| `photo` | `{ src?, alt?, hint? } \| false` | `false` | Background photo. `false` (default) → plain navy panel. An object with no `src` → placeholder, labelled with `hint` (else "Background photo"). |
| `divider` | `boolean?` | `true` | The hairline above the panel. |
| `fontStylesheet` | `string?` | Google Fonts URL | `""` if the site already loads the fonts. |
| `id` | `string?` | — | Root id. **Also seeds every field id.** |
`QuoteLabels` / `QuotePlaceholders` are `{ name?, email?, phone?, company?,
pickup?, delivery?, service?, details? }`.
## Images
| Slot | Demo hint (catalog props) | Ratio · size |
| :--- | :--- | :--- |
| `photo` (panel background) | White semi truck on a highway at dusk, side-on, motion blur, cab facing right | ~3:2 · 2400 × 1660 px |
With `photo` unset there is no placeholder at all; the hint only appears when
you pass `photo={{ hint: "…" }}` without a `src`.
The photo is `object-fit: cover` behind a 40% black scrim, anchored left, so
the subject should sit in the left half — the form card covers the right.
While `src` is unset the placeholder fills the panel with stripes; on desktop
the hint and spec line sit in the open middle of the intro column (above the
scrim). Stacked, the content covers the whole panel, so only the stripes show
and the hint lives in the placeholder's accessible name.
## Behaviour
| Input | What happens |
| :--- | :--- |
| Submit with a required field empty | The browser blocks it and points at the field. |
| Submit with a malformed email | Same — `type="email"`. |
| Submit with the select on its prompt | Same — the prompt is a placeholder label option (`value=""`, first). |
| <kbd>Tab</kbd> | Phone link, email link, the eight controls in order, submit. |
Hover: inputs darken their border, the button darkens and its arrow nudges.
## Accessibility
- **Every control has a real `<label for>`.** Ids are `<id>-name`,
`<id>-email`, … from the `id` prop, or a random per-render suffix — never
from position, so two panels on a page cannot collide.
- The form is named by the heading (`aria-labelledby`) when there is one,
which makes it a form landmark. With no heading it is unnamed; pass one.
- The asterisks are `aria-hidden`; `required` does the announcing.
- `autocomplete` on name, email, phone and company.
- Inputs are **16px** so iOS Safari does not zoom on focus.
- Focus is an **outline** (2px accent, 1px offset), not a shadow, so it
survives forced colours. Under `forced-colors: active` fields, the button,
the phone pill, the card and the strip also get real borders.
- The phone pill and email link are 44px tall.
- The background photo is decorative by default (`alt=""`); the placeholder
is `role="img"` with `aria-label="Image placeholder: <hint>"`.
## Reduced motion
The only motion is 200ms colour transitions and the arrow nudge; under
`prefers-reduced-motion: reduce` the duration token collapses to `0.01ms`.
## Without JavaScript
Identical. There is no script.
## Responsive
Container queries on the 1200px container, so it behaves the same in a
narrower column:
| Container width | Layout |
| :--- | :--- |
| < 560 | Intro stacked over the form; one field per row; 24px panel / 20px card padding; heading 30px. |
| 560–899 | Still stacked; fields two-up; 32px card padding. |
| ≥ 900 | Measured desktop split: 466fr intro + 60px + 594fr form in 40px padding; heading 48px. |
The heading scales `clamp(30px, 4cqi, 48px)` — 30px at phone, 48px at 1200.
## Approximations
- **Copy and labels are original** (the catalog demo's Tidewater Freight copy is passed as props, not defaulted); the field set matches
the reference's shape (name, email, phone, company, two locations, a
service select, a details box).
- **Input borders** are a 1px `rgba(10,37,64,.14)` hairline. The dump did
not capture the reference's edge (probably an inset shadow); it looks the
same in the screenshot.
- **Icons on the phone pill and email link** were requested in the brief;
the reference has none. The pill is 44px tall (reference 40px) for touch.
- **The contact strip wraps** to two lines when the email is long (the
default one is), centred; the reference's short address fits on one line.
- **Scrim** is 40% near-black, as measured; backdrop blur on the strip (6px)
is a guess — the dump does not expose it.
- **Tablet** (810–1199) was not captured; it is interpolated as above.
- Not verified in a real browser session with Playwright, keyboard or
forced colours — only headless render checks at 320/390/834/1024/1280/1440
(no horizontal overflow, no orphan labels, no duplicate ids).
===== END src/components/QuoteRequestPanel.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.
--- NOT PROVEN ---
- ClientRouter: not applicable — this component ships no script.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.