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: "Contact switchboard"
Library page: https://astro.baysixmedia.com/components/contact-switchboard/
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/ContactSwitchboard.astro =====
---
/**
* ContactSwitchboard (candidate — original, role `contact`)
*
* A contact section organised around why the visitor is getting in touch, not around the list of
* channels. Under a split header (heading left, intro right) sits one framed board: each row is a
* reason — "Something is broken right now", "A quote for new work", "Billing and paperwork" — with a
* line on what it covers, a line on what happens next, and the channel that suits it best as a
* full-strength action (call, email, or the site's own booking/quote page), with an optional second
* channel under it. One row can be marked as the priority route and is set in reverse, so the urgent
* path is the first thing the eye lands on. The board closes on a tinted strip of the standing facts
* — hours, towns served, the office address — each only if supplied.
*
* Truth rules. Nothing about the business has a default. A route renders only when at least one of
* its channels was supplied (a phone number, an email address, a real page link); a channel that was
* not supplied is dropped from every row. With no routes passed, one plain row per supplied channel
* is drawn. With no channel at all, the section renders nothing. tel: and mailto: are built from the
* supplied values; the address links only to a supplied https maps URL.
*
* No script. Container queries: stacked below 600px of the section's own width, reason | actions from
* 600px, reason | what happens | actions with column heads from 900px.
*
* Reads, never declares: --csw-bg, --csw-panel, --csw-text, --csw-text-muted, --csw-brand,
* --csw-brand-text, --csw-border, --csw-strip, --csw-family.
*/
export type ContactSwitchboardChannel = "phone" | "email" | "request";
export interface ContactSwitchboardRoute {
/** The reason for getting in touch, in the visitor's words. */
need: string;
/** One line on what the reason covers. */
detail?: string;
/** One line on what happens after the visitor uses this route. */
expect?: string;
/** The channels for this reason, best first: the first supplied one is the main action, the second a quieter alternative. */
via: readonly ContactSwitchboardChannel[];
/** Set this row in reverse as the urgent path. */
priority?: boolean;
}
export interface ContactSwitchboardProps {
heading: string;
headingLevel?: "h2" | "h3";
eyebrow?: string;
intro?: string;
phone?: { number: string; label?: string; href?: string } | null;
email?: string;
/** The site's own booking, quote or form page. */
request?: { label: string; href: string } | null;
routes?: readonly ContactSwitchboardRoute[];
hours?: string | readonly string[];
serviceAreas?: readonly string[];
address?: string | readonly string[];
addressHref?: string;
labels?: {
call?: string;
email?: string;
byPhone?: string;
byEmail?: string;
need?: string;
expect?: string;
reach?: string;
hours?: string;
areas?: string;
address?: string;
};
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends ContactSwitchboardProps {}
const {
heading,
headingLevel = "h2",
eyebrow = "",
intro = "",
phone = null,
email = "",
request = null,
routes = [],
hours,
serviceAreas = [],
address,
addressHref,
labels = {},
fontStylesheet = "https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400..700&display=swap",
class: className,
id,
} = Astro.props;
const L = {
call: "Call",
email: "Email",
byPhone: "By phone",
byEmail: "By email",
need: "If you need",
expect: "What happens",
reach: "Best way to reach us",
hours: "Hours",
areas: "Towns served",
address: "Office",
...labels,
};
const safeHref = (href: string | undefined) => {
const h = (href ?? "").trim();
return /^(https?:\/\/|mailto:|tel:|#|\/(?!\/)|\.{1,2}\/|\?)/i.test(h) ? h : "";
};
const lines = (v: string | readonly string[] | undefined) =>
(Array.isArray(v) ? v : typeof v === "string" && v ? [v] : []).map((x) => String(x).trim()).filter(Boolean);
/* ---- the channels that were actually supplied ---- */
const number = phone?.number?.trim() ?? "";
const digits = number.replace(/\D/g, "");
const telHref = digits.length >= 7 ? `tel:${number.startsWith("+") ? "+" : ""}${digits}` : "";
const callHref = number ? safeHref(phone?.href) || telHref : "";
const mail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? email.trim() : "";
/* Break points for a long address: after @, before dots, after hyphens and underscores. */
const mailParts = mail.split(/(?<=[@_-])|(?=\.)/);
const requestHref = request?.label?.trim() ? safeHref(request.href) : "";
type Action = { kind: ContactSwitchboardChannel; href: string };
const channel = (kind: ContactSwitchboardChannel): Action | null => {
if (kind === "phone") return callHref ? { kind, href: callHref } : null;
if (kind === "email") return mail ? { kind, href: `mailto:${mail}` } : null;
return requestHref ? { kind, href: requestHref } : null;
};
type Row = { need: string; detail: string; expect: string; priority: boolean; actions: Action[] };
const supplied: Row[] = (routes ?? [])
.map((r) => {
const via = (Array.isArray(r?.via) ? r.via : [r?.via]) as ContactSwitchboardChannel[];
const actions = [...new Set(via)].map(channel).filter((a): a is Action => a !== null).slice(0, 2);
return { need: (r?.need ?? "").trim(), detail: (r?.detail ?? "").trim(), expect: (r?.expect ?? "").trim(), priority: Boolean(r?.priority), actions };
})
.filter((r) => r.need && r.actions.length > 0);
/* No routes passed: one plain row per supplied channel. */
const fallback: Row[] = (["phone", "email", "request"] as const)
.map(channel)
.filter((a): a is Action => a !== null)
.map((a) => ({
need: a.kind === "phone" ? L.byPhone : a.kind === "email" ? L.byEmail : request!.label.trim(),
detail: "",
expect: "",
priority: false,
actions: [a],
}));
const routed = (routes ?? []).length > 0;
const rows = routed ? supplied : fallback;
/* Only the first priority row is set in reverse. */
const priorityIndex = rows.findIndex((r) => r.priority);
const hasExpect = rows.some((r) => r.expect);
const hourLines = lines(hours);
const areas = (serviceAreas ?? []).map((a) => String(a).trim()).filter(Boolean);
const addressLines = lines(address);
const mapHref = addressHref && /^https:\/\//i.test(addressHref.trim()) ? addressHref.trim() : "";
const hasFacts = hourLines.length > 0 || areas.length > 0 || addressLines.length > 0;
const uid = `${id ?? "csw"}-${Math.random().toString(36).slice(2, 7)}`;
const Heading = headingLevel;
const RowHeading = headingLevel === "h2" ? "h3" : "h4";
---
{
rows.length > 0 && (
<section class:list={["csw", { "csw--expect": hasExpect }, className]} id={id} aria-labelledby={`${uid}-title`}>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="csw-inner">
<header class="csw-head">
<div class="csw-titles">
{eyebrow.trim() && <p class="csw-eyebrow">{eyebrow.trim()}</p>}
<Heading class="csw-title" id={`${uid}-title`}>
{heading}
</Heading>
</div>
{intro.trim() && <p class="csw-intro">{intro.trim()}</p>}
</header>
<div class="csw-board">
{routed && (
<div class="csw-cols" aria-hidden="true">
<span>{L.need}</span>
{hasExpect && <span>{L.expect}</span>}
<span>{L.reach}</span>
</div>
)}
<ul class="csw-routes" role="list">
{rows.map((row, i) => (
<li class:list={["csw-route", { "csw-route--priority": i === priorityIndex }]}>
<div class="csw-need">
<RowHeading class="csw-need-title">{row.need}</RowHeading>
{row.detail && <p class="csw-detail">{row.detail}</p>}
</div>
{hasExpect && <div class="csw-expect">{row.expect && <p>{row.expect}</p>}</div>}
<div class="csw-reach">
{row.actions.map((a, n) => (
<a class:list={["csw-action", n === 0 ? "csw-action--main" : "csw-action--alt", `csw-action--${a.kind}`]} href={a.href}>
<span class="csw-action-text">
{a.kind === "phone" && (
<>
{phone?.label?.trim() || L.call} <span class="csw-action-value csw-nowrap">{number}</span>
</>
)}
{a.kind === "email" && (
<>
{L.email}{" "}
<span class="csw-action-value csw-mail">
{mailParts.map((part, k) => (
<>
{k > 0 && <wbr />}
{part}
</>
))}
</span>
</>
)}
{a.kind === "request" && request!.label.trim()}
</span>
<svg class="csw-arrow" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false">
<path d="M4 10h11M11 5.5 15.5 10 11 14.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
))}
</div>
</li>
))}
</ul>
{
hasFacts && (
<dl class="csw-facts">
{hourLines.length > 0 && (
<div class="csw-fact">
<dt>{L.hours}</dt>
<dd>
{hourLines.map((h) => (
<span class:list={["csw-line", { "csw-nowrap": h.length <= 26 }]}>{h}</span>
))}
</dd>
</div>
)}
{areas.length > 0 && (
<div class="csw-fact csw-fact--areas">
<dt>{L.areas}</dt>
<dd>
<ul class="csw-areas" role="list">
{areas.map((a) => (
<li>{a}</li>
))}
</ul>
</dd>
</div>
)}
{addressLines.length > 0 && (
<div class="csw-fact">
<dt>{L.address}</dt>
<dd>
{mapHref ? (
<a class="csw-address-link" href={mapHref}>
{addressLines.map((l) => (
<span class="csw-line">{l}</span>
))}
</a>
) : (
addressLines.map((l) => <span class="csw-line">{l}</span>)
)}
</dd>
</div>
)}
</dl>
)
}
</div>
</div>
</section>
)
}
<style>
.csw {
--_bg: var(--csw-bg, #f5f3ed);
--_panel: var(--csw-panel, #ffffff);
--_ink: var(--csw-text, #16181b);
--_soft: var(--csw-text-muted, #4b4f56);
--_brand: var(--csw-brand, #b93d0c);
--_brand-text: var(--csw-brand-text, #ffffff);
--_rule: var(--csw-border, #d8d4c9);
--_strip: var(--csw-strip, #ece8de);
--_font: var(--csw-family, "Instrument Sans", ui-sans-serif, system-ui, sans-serif);
container-type: inline-size;
background: var(--_bg);
color: var(--_ink);
font-family: var(--_font);
}
.csw *,
.csw *::before,
.csw *::after {
box-sizing: border-box;
}
.csw-inner {
max-width: 1240px;
margin: 0 auto;
padding: 64px 16px;
}
/* ---- header: heading left, intro right ---- */
.csw-head {
display: grid;
gap: 18px;
margin-bottom: 36px;
}
.csw-titles {
min-width: 0;
}
.csw-eyebrow {
margin: 0 0 12px;
color: var(--_brand);
font: 600 15px/1.4 var(--_font);
}
.csw-title {
max-width: 17ch;
margin: 0;
font: 700 clamp(32px, 8.4cqi, 60px) / 1.04 var(--_font);
letter-spacing: -0.035em;
text-wrap: balance;
overflow-wrap: break-word;
}
.csw-intro {
max-width: 46ch;
margin: 0;
color: var(--_soft);
font: 400 17px/1.6 var(--_font);
text-wrap: pretty;
}
/* ---- the board ---- */
.csw-board {
overflow: hidden;
border: 1.5px solid var(--_ink);
border-radius: 6px;
background: var(--_panel);
}
.csw-cols {
display: none;
}
.csw-routes {
margin: 0;
padding: 0;
list-style: none;
}
.csw-route {
display: grid;
gap: 16px;
padding: 24px 20px;
border-top: 1px solid var(--_rule);
}
.csw-route:first-child {
border-top: 0;
}
.csw-need,
.csw-expect,
.csw-reach {
min-width: 0;
}
.csw-need-title {
margin: 0;
font: 650 clamp(21px, 3.1cqi, 27px) / 1.2 var(--_font);
letter-spacing: -0.02em;
text-wrap: balance;
overflow-wrap: break-word;
}
.csw-detail {
max-width: 44ch;
margin: 8px 0 0;
color: var(--_soft);
font: 400 16px/1.55 var(--_font);
}
.csw-expect:empty {
display: none;
}
.csw-expect p {
max-width: 40ch;
margin: 0;
padding-left: 14px;
border-left: 3px solid var(--_brand);
color: var(--_ink);
font: 500 16px/1.5 var(--_font);
}
/* ---- actions ---- */
.csw-reach {
display: grid;
gap: 6px;
align-content: start;
}
.csw-action {
color: inherit;
text-decoration: none;
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease, text-decoration-color 0.15s ease;
}
.csw-action--main {
display: flex;
align-items: center;
gap: 12px;
min-height: 52px;
padding: 12px 18px;
border: 1.5px solid var(--_ink);
border-radius: 6px;
background: var(--_ink);
color: var(--_bg);
font: 600 17px/1.25 var(--_font);
}
.csw-action--main:hover {
border-color: var(--_brand);
background: var(--_brand);
color: var(--_brand-text);
}
.csw-action--alt {
justify-self: start;
display: inline-block;
max-width: 100%;
padding: 12px 2px;
color: var(--_ink);
font: 600 16px/1.3 var(--_font);
text-decoration: underline;
text-decoration-thickness: 1.5px;
text-decoration-color: var(--_rule);
text-underline-offset: 5px;
}
.csw-action--alt:hover {
text-decoration-color: var(--_brand);
}
.csw-action--alt .csw-arrow {
display: none;
}
.csw-action-text {
flex: 1;
min-width: 0;
}
.csw-action-value {
font-variant-numeric: tabular-nums;
}
.csw-action--main .csw-action-value {
font-weight: 700;
}
.csw-mail {
min-width: 0;
overflow-wrap: anywhere;
}
.csw-action--main .csw-mail {
display: block;
margin-top: 2px;
font-size: 15px;
font-weight: 500;
}
.csw-nowrap {
white-space: nowrap;
}
.csw-arrow {
flex: none;
}
.csw-action:focus-visible,
.csw-address-link:focus-visible {
outline: 3px solid var(--_brand);
outline-offset: 3px;
}
/* ---- the priority route, set in reverse ---- */
.csw-route--priority {
border-top-color: var(--_ink);
background: var(--_ink);
color: var(--_bg);
}
.csw-route--priority + .csw-route {
border-top-color: var(--_ink);
}
.csw-route--priority .csw-detail {
color: color-mix(in srgb, var(--_bg) 80%, var(--_ink));
}
.csw-route--priority .csw-expect p {
color: var(--_bg);
}
.csw-route--priority .csw-action--main {
border-color: var(--_brand);
background: var(--_brand);
color: var(--_brand-text);
}
.csw-route--priority .csw-action--main:hover {
border-color: var(--_bg);
background: var(--_bg);
color: var(--_ink);
}
.csw-route--priority .csw-action--alt {
color: var(--_bg);
text-decoration-color: color-mix(in srgb, var(--_bg) 45%, var(--_ink));
}
.csw-route--priority .csw-action--alt:hover {
text-decoration-color: var(--_bg);
}
.csw-route--priority .csw-action:focus-visible {
outline-color: var(--_bg);
}
/* ---- standing facts ---- */
.csw-facts {
display: grid;
margin: 0;
border-top: 1.5px solid var(--_ink);
background: var(--_strip);
}
.csw-fact {
display: grid;
gap: 6px;
min-width: 0;
padding: 18px 20px;
border-top: 1px solid var(--_rule);
}
.csw-fact:first-child {
border-top: 0;
}
.csw-fact dt {
color: var(--_soft);
font: 600 14px/1.4 var(--_font);
}
.csw-fact dd {
margin: 0;
font: 500 16px/1.5 var(--_font);
overflow-wrap: break-word;
}
.csw-line {
display: block;
}
.csw-areas {
display: flex;
flex-wrap: wrap;
margin: 0;
padding: 0;
list-style: none;
}
.csw-areas li {
white-space: nowrap;
}
.csw-areas li:not(:last-child)::after {
content: "·";
margin: 0 0.5em;
color: var(--_soft);
}
.csw-address-link {
display: inline-block;
color: var(--_ink);
text-decoration: underline;
text-decoration-color: var(--_rule);
text-decoration-thickness: 1.5px;
text-underline-offset: 4px;
}
.csw-address-link:hover {
text-decoration-color: var(--_brand);
}
/* ---- 600px: reason | actions ---- */
@container (min-width: 600px) {
.csw-inner {
padding: 80px 28px;
}
.csw-route {
grid-template-columns: minmax(0, 1fr) minmax(230px, 0.62fr);
column-gap: 28px;
align-items: start;
padding: 28px 28px;
}
.csw-expect {
grid-column: 1;
grid-row: 2;
}
.csw-reach {
grid-column: 2;
grid-row: 1;
}
.csw--expect .csw-reach {
grid-row: 1 / span 2;
}
.csw-facts {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.csw-fact {
padding: 20px 28px;
border-top: 0;
border-left: 1px solid var(--_rule);
}
.csw-fact:first-child {
border-left: 0;
}
.csw-fact--areas {
grid-column: span 2;
}
}
/* ---- 900px: reason | what happens | actions, with column heads ---- */
@container (min-width: 900px) {
.csw-inner {
padding: 104px 40px;
}
.csw-head {
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
column-gap: 64px;
align-items: end;
margin-bottom: 48px;
}
.csw-cols {
display: grid;
column-gap: 40px;
padding: 14px 36px;
border-bottom: 1px solid var(--_rule);
color: var(--_soft);
font: 600 14px/1.4 var(--_font);
}
.csw-cols,
.csw-route {
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.62fr);
}
.csw--expect .csw-cols,
.csw--expect .csw-route {
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr) minmax(280px, 0.78fr);
}
.csw-route {
column-gap: 40px;
padding: 32px 36px;
}
.csw-route:first-child {
border-top: 0;
}
.csw--expect .csw-expect {
grid-column: 2;
grid-row: 1;
padding-top: 4px;
}
.csw--expect .csw-expect:empty {
display: block;
}
.csw--expect .csw-reach {
grid-column: 3;
grid-row: 1;
}
.csw-fact {
padding: 22px 36px;
}
.csw-facts {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.csw-action {
transition: none;
}
}
@media (forced-colors: active) {
.csw-action--main {
border: 2px solid ButtonText;
}
.csw-route--priority {
border: 2px solid CanvasText;
}
}
</style>
===== END src/components/ContactSwitchboard.astro =====
===== FILE (supporting): src/components/ContactSwitchboard.md =====
# Contact switchboard
A contact section organised around why the visitor is getting in touch: one framed board of reasons — urgent problem, booking, quote, billing — each with a line on what it covers, what happens next, and the channel that suits it as a full-strength action (call, email or the site's own request page), with the urgent route set in reverse. Hours, towns served and the office address close the board in a tinted strip. Every channel and fact only if supplied.
## Integration
- A section heading (h2 by default); each route's reason is one level down. The routes are a list inside one framed board.
- Channels are stated once — phone, email, request — and routes name which to use. A channel that is not supplied is dropped from every route; a route left with no channel is dropped; with no routes, one plain row per supplied channel; with no channel at all, nothing renders.
- The first route with priority: true is set in reverse (ink ground, accent action). Use it for the genuinely urgent path only.
- Hours, towns and address render in the closing strip only when passed; the address links only to a supplied https maps URL. The number never wraps; a long email address breaks inside its own action.
- Container queries: stacked below 600px; reason | actions from 600px; column heads and reason | what happens | actions from 900px (two columns when no route has a what-happens line). Reads --csw-bg, --csw-panel, --csw-text, --csw-text-muted, --csw-brand, --csw-brand-text, --csw-border, --csw-strip and --csw-family.
- No script. Loads Instrument Sans from Google Fonts by default; pass fontStylesheet="" when the site loads it.
## Provenance
Staged as candidate `contact-switchboard` (Step 28) and admitted through the candidate pipeline. Source type: original. References (direction only): none. Quality history: quality/candidates/records/contact-switchboard.json.
===== END src/components/ContactSwitchboard.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.