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: "Route quote estimator"
Library page: https://astro.baysixmedia.com/components/route-quote-estimator/
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/RouteQuoteEstimator.astro =====
---
/**
* RouteQuoteEstimator
*
* A rounded ink card on a pale section: a lime-dot eyebrow, a big heading,
* a paragraph and two small status badges on the left; on the right a white
* panel with a live route estimator — origin, destination, mode, weight and
* shipment type — whose result box recomputes an indicative price range and
* transit time on every input, and a full-width lime link to request a
* verified quote.
*
* It is an estimate UI, not a form: nothing is submitted. The rate table
* (lanes, modes, shipment types, currency) is required props, never a
* default: with none it renders nothing.
*
* No script: the default estimate is rendered on the server and every
* control is still labelled; a <noscript> note says the figure will not
* recalculate.
*
* Reads --nv-* design tokens from any ancestor, with fallbacks. Never sets
* them.
*/
/** One origin → destination pair. Price = (base + perKg × kg) × mode × type. */
export interface EstimatorLane {
origin: string;
destination: string;
/** Fixed cost of the lane at the mode multiplier 1, in `currency`. */
base: number;
/** Cost per kilogram at the mode multiplier 1. */
perKg: number;
/** Road transit in whole days, [min, max]. Modes scale this. */
transitDays: [number, number];
}
export interface EstimatorMode {
label: string;
/** Multiplies the lane price. 1 for the reference mode. */
multiplier: number;
/** Multiplies the lane's transit days (rounded up, minimum 1). */
transitScale: number;
}
export interface EstimatorShipmentType {
label: string;
/** Multiplies the price after the mode. */
multiplier: number;
}
export interface RouteQuoteEstimatorProps {
eyebrow?: string;
heading?: string;
headingLevel?: "h2" | "h3";
text?: string;
/** Small pills under the paragraph. `[]` drops the row. */
badges?: string[];
lanes?: EstimatorLane[];
modes?: EstimatorMode[];
shipmentTypes?: EstimatorShipmentType[];
/** Half-width of the range as a fraction of the midpoint. 0.04 = ±4%. */
band?: number;
/** Range ends are rounded to the nearest multiple of this. */
roundTo?: number;
/** ISO 4217 code for Intl.NumberFormat. */
currency?: string;
locale?: string;
minWeight?: number;
maxWeight?: number;
initialOrigin?: string;
initialDestination?: string;
initialMode?: string;
initialType?: string;
initialWeight?: number;
/** Field labels, in reading order. */
labels?: {
origin?: string;
destination?: string;
mode?: string;
weight?: string;
type?: string;
estimate?: string;
note?: string;
};
quoteLabel?: string;
quoteHref?: string;
/** The section surface around the card. */
background?: string;
fontStylesheet?: string;
id?: string;
}
interface Props extends RouteQuoteEstimatorProps {}
/*
* No default rate table. Lanes, modes, shipment types and the currency are
* the caller's own rates: a built-in table would be inventing prices. With
* any of them missing the component renders nothing. (The catalog demo's
* table, solved from the reference's three measured points, lives in the
* registry's demo props.)
*/
const {
eyebrow = "",
heading = "",
headingLevel = "h2",
text = "",
badges = [],
lanes: lanesProp = [],
modes: modesProp = [],
shipmentTypes: typesProp = [],
band = 0.04,
roundTo = 10,
currency = "",
locale = "en-US",
minWeight = 1,
maxWeight = 40000,
initialOrigin,
initialDestination,
initialMode,
initialType,
initialWeight = 1250,
labels = {},
quoteLabel = "",
quoteHref = "",
background = "#eef3f8",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700;800&display=swap",
id,
} = Astro.props;
const L = {
origin: "Origin",
destination: "Destination",
mode: "Mode",
weight: "Weight (kg)",
type: "Shipment type",
estimate: "Indicative estimate",
note: "Rate table estimate",
...labels,
};
const filled = (v: unknown): v is string => typeof v === "string" && v.trim() !== "";
const finite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
/* Only complete rows count: a lane needs both ends, both rates and a
[min, max] transit; a mode or type needs a label and its numbers. */
const lanes = (lanesProp ?? []).filter(
(l) =>
l &&
filled(l.origin) &&
filled(l.destination) &&
finite(l.base) &&
finite(l.perKg) &&
Array.isArray(l.transitDays) &&
l.transitDays.length === 2 &&
l.transitDays.every(finite),
);
const modes = (modesProp ?? []).filter((m) => m && filled(m.label) && finite(m.multiplier) && finite(m.transitScale));
const shipmentTypes = (typesProp ?? []).filter((t) => t && filled(t.label) && finite(t.multiplier));
/* No currency (or one Intl does not know) → no prices to show. */
let money: Intl.NumberFormat | null = null;
try {
if (filled(currency)) money = new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 });
} catch {
money = null;
}
const hasRates = lanes.length > 0 && modes.length > 0 && shipmentTypes.length > 0 && money !== null;
const badgeList = (badges ?? []).filter(filled);
const showCta = filled(quoteLabel) && filled(quoteHref);
const Heading = headingLevel;
const uid = id ?? `rqe-${Math.random().toString(36).slice(2, 9)}`;
const unique = (list: string[]) => [...new Set(list)];
const origins = unique(lanes.map((l) => l.origin));
const destinations = unique(lanes.map((l) => l.destination));
const pick = (list: string[], wanted?: string) => (wanted && list.includes(wanted) ? wanted : list[0] ?? "");
const origin = pick(origins, initialOrigin);
/* Prefer a destination that has a lane from the chosen origin. */
const destination = pick(
destinations,
initialDestination ?? lanes.find((l) => l.origin === origin)?.destination,
);
const mode = pick(
modes.map((m) => m.label),
initialMode,
);
const type = pick(
shipmentTypes.map((t) => t.label),
initialType,
);
const weight = Math.min(Math.max(initialWeight, minWeight), maxWeight);
/*
* The estimate. Written once here for the server render and once, the same,
* in the script — the table travels to it as JSON on the root.
*/
function estimate(o: string, d: string, m: string, t: string, kg: number) {
const lane = lanes.find((l) => l.origin === o && l.destination === d);
const modeRow = modes.find((x) => x.label === m);
const typeRow = shipmentTypes.find((x) => x.label === t);
if (!lane || !modeRow || !typeRow) return null;
const mid = (lane.base + lane.perKg * kg) * modeRow.multiplier * typeRow.multiplier;
const step = roundTo > 0 ? roundTo : 1;
const low = Math.round((mid * (1 - band)) / step) * step;
const high = Math.round((mid * (1 + band)) / step) * step;
const days = lane.transitDays.map((n) => Math.max(1, Math.ceil(n * modeRow.transitScale)));
return { low, high, days };
}
const transitText = (days: number[]) =>
days[0] === days[1] ? `Transit ${days[0]} ${days[0] === 1 ? "day" : "days"}` : `Transit ${days[0]}–${days[1]} days`;
const first = estimate(origin, destination, mode, type, weight);
const rangeText = first && money ? `${money.format(first.low)} – ${money.format(first.high)}` : "No rate on this lane";
const firstTransit = first ? transitText(first.days) : "Ask a planner";
const table = JSON.stringify({
lanes,
modes,
types: shipmentTypes,
band,
roundTo,
currency,
locale,
min: minWeight,
max: maxWeight,
});
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const href = showCta && SAFE_SCHEME.test(quoteHref.trim()) ? quoteHref.trim() : "#";
const numberFmt = new Intl.NumberFormat(locale);
---
{hasRates && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasRates && (
<section
class="rqe"
id={id}
style={`--rqe-bg: ${background};`}
data-rqe-root
data-rqe-table={table}
aria-labelledby={heading ? `${uid}-heading` : undefined}
aria-label={heading ? undefined : "Route estimator"}
>
<div class="rqe-card">
<div class="rqe-copy">
{
eyebrow && (
<p class="rqe-eyebrow">
<span class="rqe-dot" aria-hidden="true" />
{eyebrow}
</p>
)
}
{
heading && (
<Heading class="rqe-heading" id={`${uid}-heading`}>
{heading}
</Heading>
)
}
{text && <p class="rqe-text">{text}</p>}
{
badgeList.length > 0 && (
<ul class="rqe-badges">
{badgeList.map((badge) => (
<li class="rqe-badge">
<svg viewBox="0 0 8 8" width="8" height="8" aria-hidden="true" focusable="false">
<circle cx="4" cy="4" r="3.25" fill="none" stroke="currentColor" stroke-width="1.1" />
</svg>
{badge}
</li>
))}
</ul>
)
}
</div>
<div class="rqe-panel">
<div class="rqe-grid" data-rqe-form>
<div class="rqe-field">
<label class="rqe-label" for={`${uid}-origin`}>{L.origin}</label>
<select class="rqe-control" id={`${uid}-origin`} data-rqe-origin>
{origins.map((o) => <option selected={o === origin}>{o}</option>)}
</select>
</div>
<div class="rqe-field">
<label class="rqe-label" for={`${uid}-destination`}>{L.destination}</label>
<select class="rqe-control" id={`${uid}-destination`} data-rqe-destination>
{destinations.map((d) => <option selected={d === destination}>{d}</option>)}
</select>
</div>
<div class="rqe-field">
<label class="rqe-label" for={`${uid}-mode`}>{L.mode}</label>
<select class="rqe-control" id={`${uid}-mode`} data-rqe-mode>
{modes.map((m) => <option selected={m.label === mode}>{m.label}</option>)}
</select>
</div>
<div class="rqe-field">
<label class="rqe-label" for={`${uid}-weight`}>{L.weight}</label>
<input
class="rqe-control"
id={`${uid}-weight`}
type="number"
inputmode="decimal"
min={minWeight}
max={maxWeight}
step="any"
value={weight}
aria-describedby={`${uid}-hint`}
data-rqe-weight
/>
</div>
<div class="rqe-field rqe-field--wide">
<label class="rqe-label" for={`${uid}-type`}>{L.type}</label>
<select class="rqe-control" id={`${uid}-type`} data-rqe-type>
{shipmentTypes.map((t) => <option selected={t.label === type}>{t.label}</option>)}
</select>
</div>
<p class="rqe-hint" id={`${uid}-hint`} aria-live="polite" data-rqe-hint></p>
</div>
<div class="rqe-result" aria-live="polite" aria-atomic="true" data-rqe-result>
<span class="rqe-result-label">{L.estimate}</span>
<strong class="rqe-range" data-rqe-range>{rangeText}</strong>
<span class="rqe-transit" data-rqe-transit>{firstTransit}</span>
<span class="rqe-note">
{L.note}
<noscript>{` · shown for ${origin} → ${destination}, ${numberFmt.format(weight)} kg; it recalculates with JavaScript on`}</noscript>
</span>
</div>
{showCta && (
<a class="rqe-cta" href={href}>
<span>{quoteLabel}</span>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">
<path
d="M3 8h9.5M8.5 4l4 4-4 4"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</a>
)}
</div>
</div>
</section>
)}
<style>
.rqe {
--rqe-ink: var(--nv-ink, #08110f);
--rqe-lime: var(--nv-lime, #b7ff39);
--rqe-muted: var(--nv-muted, #6b7b78);
--rqe-field: #f6f8f5;
--rqe-line: #d9deda;
--rqe-font: var(--nv-font, Manrope, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
--rqe-font-ui: var(--nv-font-ui, Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
box-sizing: border-box;
padding: 48px 18px;
background: var(--rqe-bg);
color: var(--rqe-ink);
font-family: var(--rqe-font);
}
.rqe *,
.rqe *::before,
.rqe *::after {
box-sizing: border-box;
}
.rqe-card {
display: flex;
flex-direction: column;
gap: 36px;
max-width: 1240px;
margin: 0 auto;
padding: 36px 22px 22px;
border-radius: 32px;
background: var(--rqe-ink);
overflow: hidden;
}
/* ---- copy ---- */
.rqe-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
font-family: var(--rqe-font-ui);
color: #fff;
}
.rqe-eyebrow {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 24px;
font-size: 12px;
font-weight: 800;
line-height: 14.4px;
letter-spacing: 1.2px;
text-transform: uppercase;
}
.rqe-dot {
flex: none;
width: 9px;
height: 9px;
border-radius: 100px;
background: var(--rqe-lime);
}
.rqe-heading {
margin: 0;
max-width: 470px;
font-size: 42px;
font-weight: 700;
line-height: 1.04;
letter-spacing: -0.045em;
color: #fff;
text-wrap: balance;
}
.rqe-text {
margin: 24px 0 0;
max-width: 440px;
font-size: 16px;
font-weight: 400;
line-height: 26.4px;
color: rgb(255 255 255 / 0.65);
}
.rqe-badges {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 32px 0 0;
padding: 0;
list-style: none;
}
.rqe-badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 13px;
border-radius: 100px;
background: #e9ffd0;
color: #547021;
font-size: 10px;
font-weight: 800;
line-height: 12px;
text-transform: uppercase;
white-space: nowrap;
}
.rqe-badge svg {
flex: none;
color: #7fb62a;
}
/* ---- estimator panel ---- */
.rqe-panel {
display: grid;
gap: 16px;
min-width: 0;
padding: 18px;
border-radius: 24px;
background: #fff;
}
.rqe-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
}
.rqe-field {
display: grid;
gap: 7px;
min-width: 0;
}
.rqe-label {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.88px;
text-transform: uppercase;
color: var(--rqe-muted);
}
/* 16px text so iOS Safari does not zoom the page on focus. */
.rqe-control {
width: 100%;
min-width: 0;
height: 52px;
margin: 0;
padding: 0 14px;
border: 1px solid var(--rqe-line);
border-radius: 14px;
background-color: var(--rqe-field);
color: var(--rqe-ink);
font: 500 16px/1.2 var(--rqe-font);
}
.rqe-control:hover {
border-color: #bfc8c2;
}
.rqe-control:focus-visible {
outline: 2px solid var(--rqe-ink);
outline-offset: 2px;
border-color: var(--rqe-ink);
}
.rqe-control[aria-invalid="true"] {
border-color: #b3261e;
}
.rqe-hint {
margin: 0;
font-size: 12px;
font-weight: 600;
line-height: 1.4;
color: #9a2019;
}
.rqe-hint:empty {
display: none;
}
.rqe-result {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 18px;
border-radius: 18px;
background: #eef8d6;
color: var(--rqe-ink);
}
.rqe-result-label {
font-size: 13px;
font-weight: 400;
}
.rqe-range {
font-size: 26px;
font-weight: 700;
line-height: 1.35;
text-align: center;
font-variant-numeric: tabular-nums;
}
.rqe-transit {
align-self: flex-end;
font-size: 12px;
font-weight: 700;
}
.rqe-note {
font-size: 11px;
font-weight: 400;
color: #64736d;
}
.rqe-cta {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 52px;
padding: 0 20px;
border-radius: 999px;
background: var(--rqe-lime);
color: var(--rqe-ink);
font: 800 12px/1.2 var(--rqe-font);
text-align: center;
text-decoration: none;
}
.rqe-cta:hover {
background: #c8ff38;
}
.rqe-cta:focus-visible {
outline: 2px solid var(--rqe-ink);
outline-offset: 3px;
}
/* ---- tablet: two-column fields ---- */
@media (min-width: 810px) {
.rqe {
padding: 90px 40px;
}
.rqe-card {
gap: 44px;
padding: 52px 48px 48px;
border-radius: 36px;
}
.rqe-heading {
font-size: 50px;
}
.rqe-panel {
padding: 26px;
border-radius: 28px;
}
.rqe-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rqe-field--wide,
.rqe-hint {
grid-column: 1 / -1;
}
.rqe-result {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
}
.rqe-range {
text-align: center;
}
.rqe-transit {
align-self: center;
justify-self: end;
text-align: right;
}
.rqe-note {
grid-column: 1 / -1;
}
.rqe-range {
white-space: nowrap;
}
}
/* ---- desktop: the measured layout ---- */
@media (min-width: 1200px) {
.rqe {
padding: 120px 64px;
}
/* Measured: the 638px panel runs past the card's 60px padding and
stops 8px from its right edge; reproduced as an 8px right pad. */
.rqe-card {
flex-direction: row;
align-items: center;
gap: 64px;
padding: 60px 8px 60px 60px;
}
.rqe-copy {
flex: 0 1 470px;
}
.rqe-heading {
font-size: 54px;
line-height: 56.16px;
letter-spacing: -2.43px;
}
.rqe-panel {
flex: 0 1 638px;
}
.rqe-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (forced-colors: active) {
.rqe-card,
.rqe-panel,
.rqe-result {
border: 1px solid CanvasText;
}
.rqe-badge {
border: 1px solid CanvasText;
}
.rqe-cta {
border: 2px solid LinkText;
}
.rqe-control:focus-visible,
.rqe-cta:focus-visible {
outline-color: Highlight;
}
.rqe-dot {
forced-color-adjust: none;
}
}
</style>
<script>
/*
* RouteQuoteEstimator controller.
*
* Reads the rate table from the root's data attribute, recomputes on every
* input and writes the range and transit into an aria-live region. A weight
* that is empty, non-numeric or out of range keeps the last valid estimate
* and shows a hint instead. Nothing is submitted.
*
* One closure per instance, every listener on one AbortController, torn
* down before a client-side navigation swaps the document.
*/
interface Lane {
origin: string;
destination: string;
base: number;
perKg: number;
transitDays: [number, number];
}
interface Table {
lanes: Lane[];
modes: { label: string; multiplier: number; transitScale: number }[];
types: { label: string; multiplier: number }[];
band: number;
roundTo: number;
currency: string;
locale: string;
min: number;
max: number;
}
interface Instance {
destroy(): void;
}
function initRouteEstimator(root: HTMLElement): Instance | undefined {
/* Idempotent: booting twice is a no-op, not a second set of listeners. */
if (root.dataset.rqeReady === "") return undefined;
let table: Table;
try {
table = JSON.parse(root.dataset.rqeTable ?? "");
} catch {
return undefined;
}
const q = <T extends Element>(sel: string) => root.querySelector<T>(sel);
const origin = q<HTMLSelectElement>("[data-rqe-origin]");
const destination = q<HTMLSelectElement>("[data-rqe-destination]");
const mode = q<HTMLSelectElement>("[data-rqe-mode]");
const type = q<HTMLSelectElement>("[data-rqe-type]");
const weight = q<HTMLInputElement>("[data-rqe-weight]");
const range = q<HTMLElement>("[data-rqe-range]");
const transit = q<HTMLElement>("[data-rqe-transit]");
const hint = q<HTMLElement>("[data-rqe-hint]");
const form = q<HTMLElement>("[data-rqe-form]");
if (!origin || !destination || !mode || !type || !weight || !range || !transit || !hint || !form) {
return undefined;
}
root.dataset.rqeReady = "";
const controller = new AbortController();
const { signal } = controller;
const money = new Intl.NumberFormat(table.locale, {
style: "currency",
currency: table.currency,
maximumFractionDigits: 0,
});
const num = new Intl.NumberFormat(table.locale);
const served = { range: range.textContent, transit: transit.textContent };
let lastKg = Number(weight.value) || table.min;
function readWeight(): number | null {
const raw = weight!.value.trim();
if (raw === "") return null;
const kg = Number(raw);
if (!Number.isFinite(kg) || kg < table.min || kg > table.max) return null;
return kg;
}
function update() {
const kg = readWeight();
if (kg === null) {
weight!.setAttribute("aria-invalid", "true");
hint!.textContent = `Enter a weight from ${num.format(table.min)} to ${num.format(table.max)} kg. Showing the last valid estimate.`;
} else {
weight!.removeAttribute("aria-invalid");
hint!.textContent = "";
lastKg = kg;
}
const lane = table.lanes.find((l) => l.origin === origin!.value && l.destination === destination!.value);
const m = table.modes.find((x) => x.label === mode!.value);
const t = table.types.find((x) => x.label === type!.value);
if (!lane || !m || !t) {
range!.textContent = "No rate on this lane";
transit!.textContent = "Ask a planner";
return;
}
const mid = (lane.base + lane.perKg * lastKg) * m.multiplier * t.multiplier;
const step = table.roundTo > 0 ? table.roundTo : 1;
const low = Math.round((mid * (1 - table.band)) / step) * step;
const high = Math.round((mid * (1 + table.band)) / step) * step;
const [a, b] = lane.transitDays.map((n) => Math.max(1, Math.ceil(n * m.transitScale)));
const nextRange = `${money.format(low)} – ${money.format(high)}`;
const nextTransit = a === b ? `Transit ${a} ${a === 1 ? "day" : "days"}` : `Transit ${a}–${b} days`;
/* Write only on change, so the live region does not repeat itself. */
if (range!.textContent !== nextRange) range!.textContent = nextRange;
if (transit!.textContent !== nextTransit) transit!.textContent = nextTransit;
}
form.addEventListener("input", update, { signal });
form.addEventListener("change", update, { signal });
/* The browser may have restored different values on back/forward. */
update();
function destroy() {
controller.abort();
range!.textContent = served.range;
transit!.textContent = served.transit;
hint!.textContent = "";
weight!.removeAttribute("aria-invalid");
delete root.dataset.rqeReady;
}
document.addEventListener("astro:before-swap", destroy, { once: true, signal });
return { destroy };
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-rqe-root]").forEach(initRouteEstimator);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
/* Fires on every view transition in a project using <ClientRouter />. */
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/RouteQuoteEstimator.astro =====
===== FILE (supporting): src/components/RouteQuoteEstimator.md =====
# Route quote estimator
A rounded ink card on a pale band. Left: a lime-dot eyebrow, a big heading,
a paragraph and two small badges. Right: a white panel with a live estimator
— origin, destination, mode, weight and shipment type — whose result box
recomputes an indicative price range and transit time on every input, and a
full-width lime link to request a verified quote.
Catalog page: `/components/route-quote-estimator`
## Files required
**`RouteQuoteEstimator.astro`** — the whole component. No imports, no
packages, no images, no global stylesheet. One small script for the live
calculation.
`RouteQuoteEstimator.md` — this file. Not required to run.
## Basic usage
```astro
---
import RouteQuoteEstimator from "../components/RouteQuoteEstimator.astro";
---
<RouteQuoteEstimator
quoteLabel="Request a quote"
quoteHref="/quote"
lanes={[
{ origin: "Oakland, CA", destination: "Reno, NV", base: 420, perKg: 0.31, transitDays: [1, 1] },
{ origin: "Oakland, CA", destination: "Portland, OR", base: 690, perKg: 0.42, transitDays: [1, 2] },
]}
modes={[{ label: "Road freight", multiplier: 1, transitScale: 1 }]}
shipmentTypes={[{ label: "Palletised cargo", multiplier: 1 }]}
currency="USD"
/>
```
**The rate table has no default.** `lanes`, `modes`, `shipmentTypes` and
`currency` are your own rates; if any is missing (or `currency` is not a
code `Intl` knows) the component renders nothing, rather than showing prices
nobody set. Incomplete rows are dropped: a lane needs `origin`, `destination`,
numeric `base` / `perKg` and a two-number `transitDays`; a mode needs `label`,
`multiplier`, `transitScale`; a type needs `label`, `multiplier`.
It is **an estimate UI, not a form**: nothing is submitted. The button is a
link to `quoteHref`.
## The rate table
```
midpoint = (lane.base + lane.perKg × kg) × mode.multiplier × type.multiplier
low = round(midpoint × (1 − band) / roundTo) × roundTo
high = round(midpoint × (1 + band) / roundTo) × roundTo
transit = lane.transitDays each × mode.transitScale, rounded up, minimum 1
```
Origins and destinations are the unique values across `lanes`. A pair with
no lane shows "No rate on this lane / Ask a planner".
The catalog demo passes a table (through the registry's demo props, not as
a default) that reproduces the reference's three measured points
**exactly** (road freight, standard cargo, ±4%, $10 rounding):
| Input | Midpoint | Shown |
| :--- | :--- | :--- |
| Rotterdam → Berlin, 1250 kg | 700 + 0.464 × 1250 = 1,280 | $1,230 – $1,330 |
| Rotterdam → Paris, 1250 kg | 715 + 0.464 × 1250 = 1,295 | $1,240 – $1,350 |
| Rotterdam → Berlin, 5000 kg | 700 + 0.464 × 5000 = 3,020 | $2,900 – $3,140 |
Berlin's two points fix `perKg = 1740 / 3750 = 0.464` and `base = 700`;
Paris keeps the rate with `base = 715`. The other ten lanes (Antwerp,
Hamburg, Milan, Warsaw), the rail/express modes and the three non-standard
shipment types are invented in proportion. They are demo figures only and
never ship as a default.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `""` | Drawn only when passed. |
| `heading` | `string?` | `""` | Drawn only when passed; without it the section is labelled "Route estimator". |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | |
| `text` | `string?` | `""` | Drawn only when passed. |
| `badges` | `string[]?` | `[]` | Pale-lime pills. `[]` drops the row. |
| `lanes` | `EstimatorLane[]?` | `[]` — required | `{ origin, destination, base, perKg, transitDays: [min, max] }` |
| `modes` | `EstimatorMode[]?` | `[]` — required | `{ label, multiplier, transitScale }` |
| `shipmentTypes` | `EstimatorShipmentType[]?` | `[]` — required | `{ label, multiplier }` |
| `band` | `number?` | `0.04` | Half-width of the range (±4%). |
| `roundTo` | `number?` | `10` | Range ends round to this. |
| `currency` | `string?` | `""` — required | ISO 4217, for `Intl.NumberFormat`. |
| `locale` | `string?` | `"en-US"` | |
| `minWeight` / `maxWeight` | `number?` | `1` / `40000` | Valid weight range, kg. |
| `initialOrigin` / `initialDestination` / `initialMode` / `initialType` | `string?` | first of each | Must match a label; otherwise the first. |
| `initialWeight` | `number?` | `1250` | Clamped to the range. |
| `labels` | `object?` | English | `origin`, `destination`, `mode`, `weight`, `type`, `estimate`, `note`. |
| `quoteLabel` | `string?` | `""` | The link is drawn only with both `quoteLabel` and `quoteHref`. |
| `quoteHref` | `string?` | `""` | Unsafe schemes become `#`. |
| `background` | `string?` | `#eef3f8` | The band around the card. |
| `fontStylesheet` | `string?` | Manrope + Inter on Google Fonts | `""` skips the `<link>`. |
| `id` | `string?` | — | Root id. Also seeds every control id. |
## Behaviour
- Recomputes on every `input` and `change` inside the panel, and once on
boot (so values the browser restored on back/forward are honoured).
- The range and transit sit in an `aria-live="polite" aria-atomic="true"`
region, written only when the text actually changes.
- Weight: empty, non-numeric or outside `minWeight`–`maxWeight` keeps the
**last valid estimate**, sets `aria-invalid="true"` and shows a hint
("Enter a weight from 1 to 40,000 kg. Showing the last valid estimate.")
linked by `aria-describedby` and itself `aria-live="polite"`.
- Currency via `Intl.NumberFormat`, no decimals.
- No hover transforms or animation were measured on this section; none are
added. The lime link's fill lightens to `#c8ff38` on hover (unmeasured —
see Approximations).
## Accessibility
- Every control has a visible `<label for>`.
- Controls are 16px text (no iOS focus zoom), 52px tall, with a 2px ink
`:focus-visible` ring; the link has one too.
- Native `<select>` and `<input type="number">`, so keyboard behaviour is
the browser's.
- Forced colours: card, panel, result box and badges get borders, the link a
2px LinkText border, focus rings use Highlight.
- Nothing scrolls or takes focus on load.
## Without JavaScript
The default estimate is computed on the server and shown; controls are still
labelled and operable. A `<noscript>` line in the result note says which
route and weight the figure is for and that it recalculates with JavaScript.
## Layout
- **≥ 1200px (measured):** band padding 120px, card 1240 wide, radius 36,
padding 60 (8 on the right — the 638px panel runs past the card's padding
in the reference), 64px gap. Copy column 470px, heading 54/56.16 −2.43px
Inter, paragraph 16/26.4 at 65% white, badges 32px `#e9ffd0` on
`#547021` 10px/800. Panel 638px, radius 28, padding 26; fields in three
columns, 11px/800 Manrope labels in `#6b7b78`, 52px controls on
`#f6f8f5` with a `#d9deda` border, radius 14; result box `#eef8d6`,
radius 18, label / 26px range / transit on one row with the note under;
52px lime link, radius 999.
- **810–1199px (interpolated, no tablet capture):** card stacks, fields in
two columns, result stays on one row.
- **< 810px:** 48px/18px band, card padding 36/22, radius 32; fields one
column; the range centred on its own line with transit right-aligned.
## Images and credits
None. The component uses no images.
## Approximations
- **Section band.** The brief called this a navy-to-ink section; the dump
and screenshots show a **pale `#eef3f8` band** around an ink card, and
that is what is built.
- **Options.** The reference's selects held 2/2/1/1 options; the catalog
demo table has 3 origins, 4 destinations, 3 modes and 4 shipment types so the
calculator has something to do. Only the three measured points are
calibrated.
- **Button.** Measured as a `<button>` with the fallback `sans-serif` font
(a Framer quirk); built as a link in Manrope with a drawn arrow instead of
"->". Hover fill `#c8ff38` is an addition, not measured.
- **Range separator** is an en dash ("$1,230 – $1,330"); the reference uses
a hyphen.
- **Weight label** reads "Weight (kg)"; the reference says "Weight".
- **Font split** follows the dump: Inter for the copy side, Manrope for the
panel.
- **Lime** measured `#b7ff39`, used as the `--nv-lime` fallback.
- **Tablet** is interpolated; the heading keeps two lines at desktop instead
of the reference's forced three.
===== END src/components/RouteQuoteEstimator.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: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.
--- 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.