Header — start here
Install as site header
The one to use for a header. It carries every file inline with its destination path, and tells the destination agent to inventory the existing routes and CSS before replacing anything — the parts that go wrong when a header meets a real layout.
Read it first
Install "Business details footer" from my Astro Component Library as this project's site header.
This is a header, not a page section. It replaces something that already exists, over routes that already exist, under CSS that already exists. Work through the steps in order and report as you go.
--- WHAT THIS HEADER IS ---
Component: Business details footer
Library page: https://astro.baysixmedia.com/components/business-details-footer/
Navigation mode: static — a flat list of links, with no panels to open
Position mode: normal
Desktop dropdowns: no
Separate mobile menu: no
Locks body scroll while open: no
Astro client navigation: supported — it re-initialises on astro:page-load and detaches its listeners before each swap
Required files: 1
Dependencies: none — no npm packages, no CSS framework, no UI framework
Assets: none
Route data is passed through props. The prop interface above is the shape to map the destination's routes into.
Known integration notes for this header:
- Goes at the end of the layout, after <slot />, outside <main>. It is a <footer>: at the top level that is the contentinfo landmark, so do not wrap it in another.
- `businessName` is the only required prop. Phone, email, address, hours, towns, links, social profiles and the action each render only when passed; a column with nothing in it is not drawn.
- The phone number is a `tel:` link built from its digits; pass `phone.href` to override. Words in `phone.number` produce no link.
- The service and page link columns are each a <nav> labelled by their column title, so the footer adds up to two navigation landmarks.
- The copyright reads "© {current year} {businessName}" unless `copyright` is passed; an empty string hides it.
- Loads Manrope from Google Fonts by default. Pass fontStylesheet="" on a site that already loads it.
--- EXPECTED BEHAVIOUR ---
Desktop: every link is reachable in one tab pass, with no panels to open.
Mobile: the same markup reflows; there is no separate menu to open.
Accessibility: a single landmark <nav> with an accessible name, aria-current on the link for the current route, visible focus on every control, 44x44px minimum targets, full prefers-reduced-motion support, and a working no-JavaScript fallback. None of this may be removed while adapting the design.
--- STEPS ---
1. Inspect this project before editing anything. Find its Astro version, whether it uses <ClientRouter /> or view transitions, its global stylesheet, and its existing header. Report what you found before changing a file.
2. Find the real shared layout — the one every page actually renders through — and the current header inside it. Do not assume src/layouts/Layout.astro.
3. Inventory every current navigation route: read the existing header, src/pages/, and any route or nav-data file. List every label and href before touching anything.
4. Map those real routes into this component's navigation structure. Use the destination's own routes and labels. Do not ship the demo tree, and do not invent routes to fill out a panel that looks empty.
5. Preserve the destination's branding and approved copy: its brand name, wordmark, logo mark, button labels and tone. Adapt the component's colour and type props to the destination's tokens.
6. Identify global CSS in this project that targets header, nav, a, button, ul or body, and anything that sets overflow or transform on an ancestor of the header. Those are what break an installed header. Report the collisions and how you resolved each one.
7. Build the replacement alongside the existing header first. Remove the old one only once the new one renders and works on every route.
8. Remove the old header's JavaScript and CSS once it is gone — menu toggles, scroll listeners, body-scroll locks, media-query handlers and their stylesheets. Leaving them causes double body locks and phantom listeners.
9. Make sure exactly one header renders. Check for a second one in a nested layout, in an individual page, or left behind in the old markup.
10. Wire up positioning. It sits in the normal flow and scrolls away with the page. Nothing needs reserving for it.
11. No layout height needs reserving for this one — say so explicitly rather than adding padding that would leave a gap.
12. Preserve the skip link. If the project has one it must still be the first focusable element and must still land on the main content; if a normal header would cover the target, add scroll-margin-top to it. If the project has no skip link, add one.
13. Leave analytics, meta tags, SEO, structured data and unrelated scripts in the layout exactly as they are. Removing a header is not a reason to touch anything else in <head>.
14. Make it work on a normal page load and, if this project uses client-side routing, across client navigation too: state reset between pages, no listeners accumulating, no body scroll lock surviving a navigation.
15. Visit every real route in this project and confirm the header renders, the correct link is marked current, and every href resolves. No 404s.
16. Test the whole site at 1440, 1280, 1024, 834, 390 and 320px: no horizontal overflow, no clipped panels, keyboard operation throughout, and the header never covering focused content.
--- FILES ---
Write each block below to the path in its header. Only the file marked (required) is needed to render; the supporting files are documentation and worked examples.
===== FILE (required): src/components/BusinessDetailsFooter.astro =====
---
/**
* BusinessDetailsFooter
*
* A site footer for a business people call, visit or book: who it is, how to
* reach it, where it works, and where everything on the site lives. A top row
* carries the name (or logo) and a line about the business opposite the phone
* number set large and an optional action. Under a rule, up to four columns —
* services, other pages, contact details, towns served — each only if given.
* A bottom bar holds the copyright, legal links and social links.
*
* Nothing about the business is assumed. No name, phone, email, address,
* hours, town, social profile, licence or link is shown unless it is passed,
* and a block with nothing in it is not drawn — with nothing at all, nothing
* renders.
*
* No script. Reads --bdf-* tokens from any ancestor, with fallbacks.
*/
export interface BusinessDetailsFooterLink {
label: string;
href: string;
}
export interface BusinessDetailsFooterProps {
/** The business name. Shown as the wordmark when there is no logo, and in the copyright. Omitted: no wordmark, no copyright. */
businessName?: string;
/** One or two sentences about the business. */
description?: string;
/** A logo in place of the text wordmark. `alt` defaults to the business name. */
logo?: { src: string; alt?: string; width?: number; height?: number } | null;
homeHref?: string;
/** A real number only. `href` defaults to `tel:` plus its digits. */
phone?: { number: string; label?: string; href?: string } | null;
email?: string;
/** The address as it should read. A string, or one line per entry. */
address?: string | readonly string[];
/** A link for the address, e.g. a maps search URL. */
addressHref?: string;
/** Real opening hours. A string, or one line per entry. */
hours?: string | readonly string[];
/** Towns or neighbourhoods served, listed in their own column. */
serviceAreas?: readonly string[];
/** A single line about the area served, e.g. "Serving the Peninsula since 2004". Shown above the list, or alone. */
serviceAreaLine?: string;
serviceAreasLabel?: string;
serviceLinks?: readonly BusinessDetailsFooterLink[];
serviceLinksLabel?: string;
/** Other pages: About, Reviews, Contact. */
primaryLinks?: readonly BusinessDetailsFooterLink[];
primaryLinksLabel?: string;
legalLinks?: readonly BusinessDetailsFooterLink[];
/** Social profiles, by name: { label: "Facebook", href: "…" }. */
social?: readonly BusinessDetailsFooterLink[];
/** One action beside the phone number. */
cta?: { label: string; href: string } | null;
contactLabel?: string;
/** Labels for the contact rows. */
labels?: { phone?: string; email?: string; address?: string; hours?: string };
/** Replaces "© {year} {businessName}". An empty string hides it. */
copyright?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends BusinessDetailsFooterProps {}
const {
businessName = "",
description = "",
logo = null,
homeHref = "/",
phone = null,
email = "",
address = "",
addressHref = "",
hours = "",
serviceAreas = [],
serviceAreaLine = "",
serviceAreasLabel = "Areas we serve",
serviceLinks = [],
serviceLinksLabel = "Services",
primaryLinks = [],
primaryLinksLabel = "Company",
legalLinks = [],
social = [],
cta = null,
contactLabel = "Contact",
labels = {},
copyright,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&display=swap",
class: className,
id,
} = Astro.props;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");
/* 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}` : "";
};
const lines = (value: string | readonly string[] | undefined) =>
(typeof value === "string" ? [value] : (value ?? [])).map((l) => (typeof l === "string" ? l.trim() : "")).filter(Boolean);
const links = (list: readonly BusinessDetailsFooterLink[] | undefined) =>
(list ?? [])
.filter((l) => l && typeof l.label === "string" && l.label.trim() && typeof l.href === "string" && l.href.trim())
.map((l) => ({ label: l.label.trim(), href: safeHref(l.href) }));
const phoneNumber = phone?.number?.trim() ?? "";
/* A call link only with somewhere to dial: words in `number` are not a number. */
const passedHref = phone?.href ? safeHref(phone.href) : "#";
const callHref = phoneNumber ? (passedHref !== "#" ? passedHref : telHref(phoneNumber)) : "";
const call = callHref
? { number: phoneNumber, label: phone?.label?.trim() || `Call ${phoneNumber}`, href: callHref }
: null;
const name = typeof businessName === "string" ? businessName.trim() : "";
const mark = logo?.src ? { src: logo.src, alt: logo.alt ?? name, width: logo.width, height: logo.height } : null;
const mail = email.trim();
const place = lines(address);
const open = lines(hours);
const areas = (serviceAreas ?? []).map((a) => (typeof a === "string" ? a.trim() : "")).filter(Boolean);
const areaLine = serviceAreaLine.trim();
const services = links(serviceLinks);
const pages = links(primaryLinks);
const legal = links(legalLinks);
const profiles = links(social);
const action = cta && cta.label?.trim() && cta.href?.trim() ? { label: cta.label.trim(), href: safeHref(cta.href) } : null;
const L = { phone: "Phone", email: "Email", address: "Address", hours: "Hours", ...labels };
const hasContact = Boolean(call || mail || place.length || open.length);
const hasAreas = Boolean(areaLine || areas.length);
const columnCount = [services.length > 0, pages.length > 0, hasContact, hasAreas].filter(Boolean).length;
const year = new Date().getFullYear();
const copy = copyright ?? (name ? `© ${year} ${name}` : "");
const hasBrand = Boolean(name || mark || description);
const hasTop = hasBrand || Boolean(call || action);
const hasBottom = Boolean(copy || legal.length || profiles.length);
---
{(hasTop || columnCount > 0 || hasBottom) && (
<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<footer class:list={["bdf", className]} id={id}>
<div class="bdf-inner">
{hasTop && (
<div class="bdf-top">
{hasBrand && (
<div class="bdf-brand">
{(name || mark) && (
<a class="bdf-mark" href={safeHref(homeHref)}>
{mark ? <img src={mark.src} alt={mark.alt} width={mark.width} height={mark.height} loading="lazy" decoding="async" /> : name}
</a>
)}
{description && <p class="bdf-desc">{description}</p>}
</div>
)}
{
(call || action) && (
<div class="bdf-actions">
{call && (
<a class="bdf-call" href={call.href} aria-label={call.label}>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
d="M6.6 3.5h2.6l1.4 4-1.9 1.3a11.5 11.5 0 0 0 6.5 6.5l1.3-1.9 4 1.4v2.6c0 1.2-1 2.1-2.2 2A16.6 16.6 0 0 1 4.6 5.7C4.5 4.5 5.4 3.5 6.6 3.5Z"
fill="currentColor"
/>
</svg>
<span>{call.number}</span>
</a>
)}
{action && (
<a class="bdf-cta" href={action.href}>
{action.label}
</a>
)}
</div>
)
}
</div>
)}
{
columnCount > 0 && (
<div class="bdf-cols" style={`--bdf-count: ${columnCount};`}>
{services.length > 0 && (
<nav class="bdf-col" aria-label={serviceLinksLabel}>
<h2 class="bdf-col-title">{serviceLinksLabel}</h2>
<ul role="list" class="bdf-links">
{services.map((l) => (
<li>
<a href={l.href}>{l.label}</a>
</li>
))}
</ul>
</nav>
)}
{pages.length > 0 && (
<nav class="bdf-col" aria-label={primaryLinksLabel}>
<h2 class="bdf-col-title">{primaryLinksLabel}</h2>
<ul role="list" class="bdf-links">
{pages.map((l) => (
<li>
<a href={l.href}>{l.label}</a>
</li>
))}
</ul>
</nav>
)}
{hasContact && (
<div class="bdf-col">
<h2 class="bdf-col-title">{contactLabel}</h2>
<address class="bdf-contact">
<dl>
{call && (
<div>
<dt>{L.phone}</dt>
<dd>
<a href={call.href}>{call.number}</a>
</dd>
</div>
)}
{mail && (
<div>
<dt>{L.email}</dt>
<dd>
<a href={`mailto:${mail}`}>{mail}</a>
</dd>
</div>
)}
{place.length > 0 && (
<div>
<dt>{L.address}</dt>
<dd>
{addressHref.trim() ? (
<a href={safeHref(addressHref)}>
{place.map((line, i) => (
<>
{i > 0 && <br />}
{line}
</>
))}
</a>
) : (
place.map((line, i) => (
<>
{i > 0 && <br />}
{line}
</>
))
)}
</dd>
</div>
)}
{open.length > 0 && (
<div>
<dt>{L.hours}</dt>
<dd>
{open.map((line, i) => (
<>
{i > 0 && <br />}
{line}
</>
))}
</dd>
</div>
)}
</dl>
</address>
</div>
)}
{hasAreas && (
<div class="bdf-col">
<h2 class="bdf-col-title">{serviceAreasLabel}</h2>
{areaLine && <p class="bdf-area-line">{areaLine}</p>}
{areas.length > 0 && (
<ul role="list" class="bdf-areas">
{areas.map((area) => (
<li>{area}</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
{hasBottom && (
<div class="bdf-bottom">
{copy && <p class="bdf-copy">{copy}</p>}
{
legal.length > 0 && (
<ul role="list" class="bdf-inline" aria-label="Legal">
{legal.map((l) => (
<li>
<a href={l.href}>{l.label}</a>
</li>
))}
</ul>
)
}
{
profiles.length > 0 && (
<ul role="list" class="bdf-inline bdf-social" aria-label="Social">
{profiles.map((l) => (
<li>
<a href={l.href} rel="me noopener">
{l.label}
</a>
</li>
))}
</ul>
)
}
</div>
)}
</div>
</footer>
</>
)}
<style>
.bdf {
--bdf-surface: var(--bdf-bg, #0f1b2d);
--bdf-ink: var(--bdf-text, #f8fafc);
--bdf-muted: var(--bdf-text-muted, #a7b3c4);
--bdf-accent: var(--bdf-brand, #1d4ed8);
--bdf-accent-ink: var(--bdf-brand-text, #ffffff);
--bdf-focus: var(--bdf-focus-ring, #93b4ff);
--bdf-rule: var(--bdf-border, rgb(255 255 255 / 0.14));
--bdf-font: var(--bdf-family, Manrope, ui-sans-serif, system-ui, sans-serif);
background: var(--bdf-surface);
color: var(--bdf-ink);
font-family: var(--bdf-font);
}
.bdf *,
.bdf *::before,
.bdf *::after {
box-sizing: border-box;
}
.bdf-inner {
max-width: 1200px;
margin: 0 auto;
padding: 48px 16px 24px;
}
.bdf-top {
display: grid;
gap: 24px;
padding-bottom: 32px;
border-bottom: 1px solid var(--bdf-rule);
}
.bdf-brand {
min-width: 0;
}
.bdf-mark {
display: inline-block;
max-width: 100%;
color: var(--bdf-ink);
font: 800 22px/1.25 var(--bdf-font);
letter-spacing: -0.01em;
text-decoration: none;
overflow-wrap: anywhere;
}
.bdf-mark img {
display: block;
max-width: min(100%, 220px);
height: auto;
max-height: 56px;
object-fit: contain;
}
.bdf-desc {
max-width: 46ch;
margin: 12px 0 0;
color: var(--bdf-muted);
font: 500 15px/1.6 var(--bdf-font);
overflow-wrap: anywhere;
}
.bdf-actions {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.bdf-call {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 48px;
color: var(--bdf-ink);
font: 800 clamp(22px, 4vw, 28px) / 1.2 var(--bdf-font);
letter-spacing: -0.01em;
text-decoration: none;
overflow-wrap: anywhere;
}
.bdf-call svg {
flex: none;
width: 24px;
height: 24px;
color: var(--bdf-focus);
}
.bdf-call:hover span {
text-decoration: underline;
text-underline-offset: 5px;
}
.bdf-cta {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 12px 22px;
border-radius: 10px;
background: var(--bdf-accent);
color: var(--bdf-accent-ink);
font: 700 16px/1.2 var(--bdf-font);
text-align: center;
text-decoration: none;
overflow-wrap: anywhere;
}
.bdf-cta:hover {
background: var(--bdf-ink);
color: var(--bdf-surface);
}
.bdf-cols {
display: grid;
gap: 32px;
padding: 32px 0;
border-bottom: 1px solid var(--bdf-rule);
}
.bdf-col {
min-width: 0;
}
.bdf-col-title {
margin: 0 0 14px;
color: var(--bdf-muted);
font: 700 12px/1.4 var(--bdf-font);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.bdf-links,
.bdf-areas,
.bdf-inline {
margin: 0;
padding: 0;
list-style: none;
}
.bdf-links {
display: grid;
gap: 2px;
}
.bdf-links a {
display: inline-block;
padding: 5px 0;
color: var(--bdf-ink);
font: 600 15px/1.45 var(--bdf-font);
text-decoration: none;
overflow-wrap: anywhere;
}
.bdf-links a:hover,
.bdf-contact a:hover,
.bdf-inline a:hover {
text-decoration: underline;
text-underline-offset: 4px;
}
.bdf-contact {
font-style: normal;
}
.bdf-contact dl {
display: grid;
gap: 14px;
margin: 0;
}
.bdf-contact dt {
color: var(--bdf-muted);
font: 600 13px/1.4 var(--bdf-font);
}
.bdf-contact dd {
margin: 2px 0 0;
font: 600 15px/1.55 var(--bdf-font);
overflow-wrap: anywhere;
}
.bdf-contact a {
color: var(--bdf-ink);
text-decoration: none;
}
.bdf-area-line {
margin: 0 0 10px;
font: 600 15px/1.55 var(--bdf-font);
overflow-wrap: anywhere;
}
.bdf-areas {
display: flex;
flex-wrap: wrap;
gap: 4px 0;
color: var(--bdf-muted);
font: 500 15px/1.6 var(--bdf-font);
}
.bdf-areas li {
max-width: 100%;
overflow-wrap: anywhere;
}
/* Towns read as a run of text, separated by dots, not a cloud of chips. */
.bdf-areas li:not(:last-child)::after {
content: "·";
margin: 0 8px;
color: var(--bdf-rule);
}
.bdf-bottom {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 20px;
color: var(--bdf-muted);
font: 500 13px/1.5 var(--bdf-font);
}
.bdf-copy {
margin: 0;
overflow-wrap: anywhere;
}
.bdf-inline {
display: flex;
flex-wrap: wrap;
gap: 0 20px;
}
.bdf-inline li {
max-width: 100%;
}
.bdf-inline a {
display: inline-block;
padding: 6px 0;
color: var(--bdf-muted);
text-decoration: none;
overflow-wrap: anywhere;
}
.bdf-mark:focus-visible,
.bdf-call:focus-visible,
.bdf-cta:focus-visible,
.bdf-links a:focus-visible,
.bdf-contact a:focus-visible,
.bdf-inline a:focus-visible {
outline: 3px solid var(--bdf-focus);
outline-offset: 3px;
border-radius: 2px;
}
@media (min-width: 640px) {
.bdf-actions {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 16px 24px;
}
.bdf-cols {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 960px) {
.bdf-inner {
padding: 64px 32px 28px;
}
.bdf-top {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 32px 48px;
}
.bdf-actions {
justify-content: flex-end;
max-width: 560px;
}
.bdf-cols {
grid-template-columns: repeat(var(--bdf-count), minmax(0, 1fr));
gap: 40px;
padding: 40px 0;
}
.bdf-bottom {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px 32px;
}
.bdf-social {
margin-left: auto;
}
}
@media (forced-colors: active) {
.bdf-cta {
border: 1px solid ButtonText;
}
}
</style>
===== END src/components/BusinessDetailsFooter.astro =====
===== FILE (supporting): src/components/BusinessDetailsFooter.md =====
# Business details footer
A site footer for a business people call, visit or book: who it is, how to
reach it, where it works, and where everything on the site lives. The top row
carries the name (or a logo) and a line about the business opposite the phone
number set large and an optional action. Under a rule, up to four columns —
services, other pages, contact details, towns served — each only if given.
A bottom bar holds the copyright, legal links and social links.
Suits plumbers, HVAC, electricians, roofers, childcare centres, professional
offices and other service-area businesses. It follows the area contact section
naturally: that one is a light section with a dark towns panel; this is a
dark footer with plain columns.
Catalog page: `/components/business-details-footer`
## Files required
- **`BusinessDetailsFooter.astro`** — the whole component. No script, no images.
- `BusinessDetailsFooter.md` — this file.
## Basic usage
```astro
<BusinessDetailsFooter
businessName="Peninsula Home Plumbing"
description="Repairs, drains and water heaters for homes on the Peninsula."
phone={{ number: "(650) 555-0142" }}
cta={{ label: "Book a visit", href: "/book" }}
email="hello@example.com"
address={["123 Example Street", "San Mateo, CA 94401"]}
hours={["Mon–Sat 7am–7pm"]}
serviceAreas={["San Mateo", "Burlingame", "Foster City"]}
serviceLinks={[{ label: "Drain cleaning", href: "/services/drains" }]}
primaryLinks={[{ label: "About", href: "/about" }, { label: "Contact", href: "/contact" }]}
legalLinks={[{ label: "Privacy", href: "/privacy" }]}
/>
```
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `businessName` | `string?` | `""` | Wordmark (when no logo) and copyright. Omitted: neither renders; with nothing else passed the footer renders nothing. |
| `description` | `string?` | `""` | A line under the name. |
| `logo` | `{ src, alt?, width?, height? } \| null` | `null` | Replaces the text wordmark. `alt` defaults to the name. |
| `homeHref` | `string?` | `"/"` | Where the name or logo links. |
| `phone` | `{ number, label?, href? } \| null` | `null` | Large call link and a contact row. See below. |
| `email` | `string?` | `""` | Contact row, `mailto:` link. |
| `address` | `string \| string[]` | `""` | Contact row, one line per entry. |
| `addressHref` | `string?` | `""` | Makes the address a link. |
| `hours` | `string \| string[]` | `""` | Contact row, one line per entry. |
| `serviceAreas` | `string[]?` | `[]` | Towns, as a dotted run of text. |
| `serviceAreaLine` | `string?` | `""` | One line about the area, above the towns or alone. |
| `serviceAreasLabel` | `string?` | `"Areas we serve"` | Column title. |
| `serviceLinks` | `{ label, href }[]?` | `[]` | Services column. |
| `serviceLinksLabel` | `string?` | `"Services"` | Column title. |
| `primaryLinks` | `{ label, href }[]?` | `[]` | Other pages column. |
| `primaryLinksLabel` | `string?` | `"Company"` | Column title. |
| `legalLinks` | `{ label, href }[]?` | `[]` | Bottom bar. |
| `social` | `{ label, href }[]?` | `[]` | Bottom bar, by name. |
| `cta` | `{ label, href } \| null` | `null` | Button beside the phone number. |
| `contactLabel` | `string?` | `"Contact"` | Column title. |
| `labels` | `{ phone?, email?, address?, hours? }` | English | Contact row labels. |
| `copyright` | `string?` | `© {year} {businessName}` | `""` hides it. |
| `fontStylesheet` | `string?` | Manrope | `""` skips it. |
| `class`, `id` | | | Root attributes. |
## The phone call to action
The large number and the phone row render **only** when `phone.number` holds
digits. The link is `tel:` plus those digits (a leading `+` is kept);
`phone.href` overrides it. Words in `number` ("call us") give no link at all.
## Trust data
Nothing about the business has a default — not even the name: no name, phone,
email, address, hours, town, social profile, licence, certification or link.
Each block renders only when it has something in it, and with nothing passed
the footer renders nothing at all. The copyright appears only with a name (or
an explicit `copyright`); its year is the build's current year.
## Layout
| Supplied | Result |
| :--- | :--- |
| Name only | Name, a rule, the copyright. |
| Any of services, pages, contact, towns | A column for each, side by side from 960px (as many columns as there are). |
| Phone or action | Beside the name from 960px; below it on smaller screens. |
## Responsive behaviour
- Below 640px everything stacks; the action is full width.
- Two columns from 640px; all columns in one row from 960px.
- Long names, emails, URLs and towns wrap anywhere rather than overflow.
## Motion
None: hover changes colour and underline instantly.
## Accessibility
- The root is a `<footer>` (the contentinfo landmark at the top level).
- The services and pages columns are each a `<nav>` labelled by their title.
- Contact details sit in an `<address>` as a `<dl>`.
- Column titles are `<h2>`s.
- Every link shows a 3px focus outline.
- Social links are named in text, with no icons that could go unnamed.
## Tokens
`--bdf-bg`, `--bdf-text`, `--bdf-text-muted`, `--bdf-brand`,
`--bdf-brand-text`, `--bdf-focus-ring`, `--bdf-border`, `--bdf-family` — set
on any ancestor to recolour it.
## Selection
Role `footer`. Annotated for the home-services and local-business markets
because of its structure — a click-to-call number, address and hours as a
column, a towns-served column — not its look. Professional and minimal; grid,
dark; no motion; supporting pressure. It requires nothing.
===== END src/components/BusinessDetailsFooter.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
No verification run has been recorded.
Passed: integration route, keyboard, no javascript, reduced motion, two instances, no overflow.
This record pre-dates source fingerprinting, so it is not tied to a specific revision.
--- 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.
--- FINISH ---
Run this project's own check and build commands. Report: the layout file you changed, the routes you mapped and what you mapped them to, the CSS collisions you found, what you deleted from the old header, and anything you could not verify. Do not commit and do not deploy.