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 "Flanked masthead header" 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: Flanked masthead header
Library page: https://astro.baysixmedia.com/components/flanked-masthead-header/
Navigation mode: static — a flat list of links, with no panels to open
Position mode: normal
Desktop dropdowns: no
Separate mobile menu: yes
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
Prop interface, verbatim from the component:
interface Props {
brandName?: string;
brandHref?: string;
logo?: { src: string; alt: string; width?: number; height?: number } | null;
items?: readonly FlankedMastheadHeaderLink[];
currentPath?: string;
phone?: { number: string; label?: string } | null;
/** One short line under the name, e.g. the area served. */
note?: string;
/** Hours or availability, as supplied: one string or one entry per line. */
hours?: string | readonly string[];
cta?: FlankedMastheadHeaderLink | null;
/** Heading over the hours in the phone menu; "Hours" by default. */
hoursLabel?: string;
navLabel?: string;
menuLabel?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
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:
- Put it first in <body> (after a skip link). In the normal flow; reserves its own height (about 168px on desktop with a page row, 130px on phones with the strip).
- The number renders only from a real `phone.number`; tel: is built from its digits. It is the left flank from 1024px and the left half of the call | book strip below.
- The pages sit in one centred row from a width computed from their labels — never on two lines; below it a centred Menu control (from 1024px) or the square control (below) opens a native <details> list.
- Hours and the note are shown in the masthead on wide screens and in the menu on phones.
- Loads Bricolage Grotesque and Instrument Sans from Google Fonts by default. Pass fontStylesheet="" on a site that loads its own faces, and theme with --fmh-bg, --fmh-text, --fmh-text-muted, --fmh-brand, --fmh-brand-text, --fmh-border, --fmh-family, --fmh-display.
--- EXPECTED BEHAVIOUR ---
Desktop: every link is reachable in one tab pass, with no panels to open.
Mobile: a real toggle button with aria-expanded and aria-controls opens the small-screen menu; crossing back to the desktop breakpoint while it is open resets it.
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/FlankedMastheadHeader.astro =====
---
/**
* FlankedMastheadHeader (candidate, role `navigation`, original)
*
* A centred masthead for a business people call or book. The name sits in the middle as a sign would,
* with the area it serves set small beneath it. The two ways to get in touch flank it as equals: the
* number (and the hours, one entry per line) on the left, the booking action on the right. Under a
* double rule, the pages run as one centred row.
*
* Below 1024px the masthead keeps the name centred with a square menu control at its right edge, and
* the two flanks drop into a split strip across the foot of the header: call on the left, book on
* the right, each half the width of the screen and 56px tall. The number never shrinks into an icon.
*
* The pages never wrap: the frontmatter estimates the width the centred row needs and picks the
* narrowest container width that holds it. Where it does not fit, a centred "Menu" control takes
* the row's place and opens the same list.
*
* Truth rules. Note, hours, phone, links and action each render only when passed. The call link needs
* a real number (tel: built from its digits). No script: the menu is a native <details> disclosure.
* In the flow; reserves its own height.
*
* Reads, never declares: --fmh-bg, --fmh-text, --fmh-text-muted, --fmh-brand, --fmh-brand-text,
* --fmh-border, --fmh-family, --fmh-display, --fmh-grid-max, --fmh-grid-gutter.
*/
export interface FlankedMastheadHeaderLink {
label: string;
href: string;
}
interface Props {
brandName?: string;
brandHref?: string;
logo?: { src: string; alt: string; width?: number; height?: number } | null;
items?: readonly FlankedMastheadHeaderLink[];
currentPath?: string;
phone?: { number: string; label?: string } | null;
/** One short line under the name, e.g. the area served. */
note?: string;
/** Hours or availability, as supplied: one string or one entry per line. */
hours?: string | readonly string[];
cta?: FlankedMastheadHeaderLink | null;
/** Heading over the hours in the phone menu; "Hours" by default. */
hoursLabel?: string;
navLabel?: string;
menuLabel?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
const {
brandName = "",
brandHref,
logo = null,
items = [],
currentPath,
phone = null,
note = "",
hours,
cta = null,
hoursLabel = "Hours",
navLabel = "Primary",
menuLabel = "Menu",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700&family=Instrument+Sans:wght@400;500;600&display=swap",
class: className,
id,
} = Astro.props;
const safeHref = (href: string | undefined) => {
const h = (href ?? "").trim();
return /^(https?:\/\/|mailto:|tel:|#|\/(?!\/))/i.test(h) ? h : null;
};
const links = items.map((l) => ({ label: (l?.label ?? "").trim(), href: safeHref(l?.href) })).filter((l): l is { label: string; href: string } => Boolean(l.label && l.href));
const action = cta && cta.label?.trim() && safeHref(cta.href) ? { label: cta.label.trim(), href: safeHref(cta.href)! } : null;
const digits = (phone?.number ?? "").replace(/[^\d+]/g, "");
const call = phone && phone.number?.trim() && digits.replace(/\D/g, "").length >= 7 ? { number: phone.number.trim(), label: (phone.label ?? "Call").trim(), href: `tel:${digits}` } : null;
const hourLines = (Array.isArray(hours) ? hours : hours ? [hours] : []).map((h) => String(h).trim()).filter(Boolean);
const noteLine = note.trim();
const name = brandName.trim();
const home = safeHref(brandHref);
const norm = (p: string) => p.replace(/\/+$/, "") || "/";
const isCurrent = (href: string) => (currentPath ? norm(currentPath) === norm(href) : false);
const hasStrip = Boolean(call || action);
const hasFacts = Boolean(noteLine || hourLines.length);
/* The name steps down by length so a long one takes two balanced lines, never three. */
const nameSize = name.length <= 18 ? "l" : name.length <= 28 ? "m" : "s";
/*
* Both flanks share one width, so the name stays on the true centre line: wide enough for the call
* line and for the action, capped so the name keeps at least 200px. Hours and a long label wrap
* inside it; nothing is cut off.
*/
const callW = call ? call.label.length * 7.6 + 10 + call.number.length * 13.4 : 0;
const ctaW = action ? Math.min(action.label.length, 24) * 8.2 + 64 : 0;
const flankW = Math.round(Math.min(260, Math.max(170, callW, ctaW)));
/* One-line budget for the centred page row: about 8px per label character, 36px gaps, 6% margin. */
const rowNeed = (links.reduce((w, l) => w + l.label.length * 8, 0) + Math.max(0, links.length - 1) * 36 + 48) * 1.06;
const STEPS = [1024, 1180, 1280] as const;
const inner = (w: number) => Math.min(w, 1232) - 96;
const inlineAt = links.length ? (STEPS.find((w) => inner(w) >= rowNeed) ?? "never") : 0;
const panelId = `${id ?? "fmh"}-menu`;
const Brand = home ? "a" : "span";
---
{(name || logo?.src || links.length > 0) && (
<header
class:list={["fmh", className]}
id={id}
data-inline={String(inlineAt)}
data-links={String(links.length)}
data-strip={hasStrip ? "" : undefined}
style={`--fmh-flank: ${flankW}px`}
>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="fmh-mast">
<div class="fmh-flank fmh-flank--call">
{call && (
<a class="fmh-call" href={call.href}>
<span class="fmh-call-label">{call.label}</span>
<span class="fmh-call-number">{call.number}</span>
</a>
)}
{hourLines.length > 0 && (
<p class="fmh-hours">
{hourLines.map((h) => <span>{h}</span>)}
</p>
)}
</div>
<div class="fmh-centre">
<Brand class="fmh-brand" href={home ?? undefined}>
{logo?.src ? (
<img class="fmh-logo" src={logo.src} alt={logo.alt || name} width={logo.width} height={logo.height} />
) : (
<span class="fmh-wordmark" data-size={nameSize}>{name}</span>
)}
</Brand>
{noteLine && <p class="fmh-dateline">{noteLine}</p>}
</div>
<div class="fmh-flank fmh-flank--book">
{action && (
<a class="fmh-cta" href={action.href}>
<span>{action.label}</span>
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M2.5 8h10M9 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.7" /></svg>
</a>
)}
</div>
</div>
{links.length > 0 && (
<nav class="fmh-row" aria-label={navLabel}>
<ul role="list">
{links.map((l) => (
<li>
<a href={l.href} aria-current={isCurrent(l.href) ? "page" : undefined}>{l.label}</a>
</li>
))}
</ul>
</nav>
)}
{links.length > 0 && (
<details class="fmh-menu">
<summary class="fmh-toggle" aria-controls={panelId}>
<span class="fmh-toggle-text">{menuLabel}</span>
<span class="fmh-toggle-icon" aria-hidden="true"><span></span><span></span></span>
</summary>
<div class="fmh-panel" id={panelId}>
<div class="fmh-panel-inner">
{/* The row and this menu are never displayed together, so one Primary nav is exposed at a time. */}
<nav aria-label={navLabel}>
<ul role="list" class="fmh-panel-links">
{links.map((l) => (
<li>
<a href={l.href} aria-current={isCurrent(l.href) ? "page" : undefined}>
<span>{l.label}</span>
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M2.5 8h10M9 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.6" /></svg>
</a>
</li>
))}
</ul>
</nav>
{hasFacts && (
<div class="fmh-panel-facts">
{hourLines.length > 0 && (
<p>
<strong>{hoursLabel}</strong>
{hourLines.map((h) => <span>{h}</span>)}
</p>
)}
{noteLine && <p class="fmh-panel-note">{noteLine}</p>}
</div>
)}
</div>
</div>
</details>
)}
{hasStrip && (
<div class="fmh-strip">
{call && (
<a class="fmh-strip-call" href={call.href}>
<span class="fmh-strip-label">{call.label}</span>
<span class="fmh-strip-number">{call.number}</span>
</a>
)}
{action && (
<a class="fmh-strip-book" href={action.href}>
<span>{action.label}</span>
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M2.5 8h10M9 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.7" /></svg>
</a>
)}
</div>
)}
</header>
)}
<style>
.fmh {
--fmh-surface: var(--fmh-bg, #faf7f2);
--fmh-ink: var(--fmh-text, #1c1a17);
--fmh-soft: var(--fmh-text-muted, #564f47);
--fmh-accent: var(--fmh-brand, #a4381c);
--fmh-accent-ink: var(--fmh-brand-text, #ffffff);
--fmh-line: var(--fmh-border, #ddd5c9);
--fmh-font: var(--fmh-family, "Instrument Sans", ui-sans-serif, system-ui, sans-serif);
--fmh-font-display: var(--fmh-display, "Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif);
--fmh-max: var(--fmh-grid-max, 1232px);
--fmh-pad: 20px;
container-type: inline-size;
position: relative;
z-index: 40;
background: var(--fmh-surface);
border-bottom: 1px solid var(--fmh-line);
color: var(--fmh-ink);
font-family: var(--fmh-font);
}
.fmh a {
-webkit-tap-highlight-color: transparent;
}
/* ---------- Masthead (compact: the name centred, the menu square at its right edge) ---------- */
.fmh-mast {
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
max-width: var(--fmh-max);
margin: 0 auto;
padding: 14px calc(var(--fmh-pad) + 56px);
}
.fmh[data-links="0"] .fmh-mast {
padding-inline: var(--fmh-pad);
}
.fmh-flank {
display: none;
}
.fmh-centre {
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
text-align: center;
}
.fmh-brand {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
max-width: 100%;
color: inherit;
text-decoration: none;
}
.fmh-wordmark {
font-family: var(--fmh-font-display);
font-size: 23px;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.08;
text-wrap: balance;
overflow-wrap: anywhere;
}
.fmh-wordmark[data-size="m"] {
font-size: 21px;
}
.fmh-wordmark[data-size="s"] {
font-size: 19px;
}
.fmh-logo {
display: block;
height: 36px;
width: auto;
max-width: 220px;
object-fit: contain;
}
.fmh-dateline {
display: none;
margin: 2px 0 0;
font-size: 14px;
font-weight: 500;
line-height: 1.35;
color: var(--fmh-soft);
text-wrap: balance;
}
/* ---------- Menu control: a square at the masthead's right edge ---------- */
.fmh-toggle {
position: absolute;
top: 14px;
right: var(--fmh-pad);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
box-sizing: border-box;
width: 44px;
height: 44px;
border: 1px solid var(--fmh-line);
border-radius: 3px;
background: var(--fmh-surface);
color: var(--fmh-ink);
cursor: pointer;
list-style: none;
font-size: 15px;
font-weight: 600;
user-select: none;
}
.fmh-toggle::-webkit-details-marker {
display: none;
}
.fmh-toggle:hover {
border-color: var(--fmh-ink);
}
.fmh-toggle-text {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.fmh-toggle-icon {
position: relative;
width: 18px;
height: 12px;
}
.fmh-toggle-icon span {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: currentColor;
transition: transform 180ms ease, top 180ms ease;
}
.fmh-toggle-icon span:nth-child(1) { top: 2px; }
.fmh-toggle-icon span:nth-child(2) { top: 8px; }
.fmh-menu[open] .fmh-toggle-icon span:nth-child(1) { top: 5px; transform: rotate(45deg); }
.fmh-menu[open] .fmh-toggle-icon span:nth-child(2) { top: 5px; transform: rotate(-45deg); }
.fmh-menu[open] .fmh-toggle {
border-color: var(--fmh-ink);
}
.fmh-panel {
position: absolute;
left: 0;
right: 0;
top: 100%;
margin-top: 1px;
background: var(--fmh-surface);
border-bottom: 1px solid var(--fmh-line);
box-shadow: 0 24px 40px -28px rgb(28 26 23 / 0.45);
}
.fmh-panel-inner {
box-sizing: border-box;
max-width: var(--fmh-max);
margin: 0 auto;
padding: 4px var(--fmh-pad) 24px;
}
.fmh-panel-links {
margin: 0;
padding: 0;
list-style: none;
}
.fmh-panel-links a {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 56px;
border-bottom: 1px solid var(--fmh-line);
color: var(--fmh-ink);
font-size: 18px;
font-weight: 500;
text-decoration: none;
}
.fmh-panel-links a svg {
flex: none;
color: var(--fmh-soft);
transition: transform 180ms ease;
}
.fmh-panel-links a:hover svg {
transform: translateX(3px);
color: var(--fmh-accent);
}
.fmh-panel-links a[aria-current="page"] {
color: var(--fmh-accent);
box-shadow: inset 3px 0 0 var(--fmh-accent);
padding-left: 14px;
}
.fmh-panel-facts {
display: grid;
gap: 16px;
margin: 20px 0 0;
}
.fmh-panel-facts p {
margin: 0;
font-size: 15px;
line-height: 1.5;
color: var(--fmh-soft);
}
.fmh-panel-facts strong {
display: block;
margin-bottom: 2px;
font-size: 14px;
font-weight: 600;
color: var(--fmh-ink);
}
.fmh-panel-facts span {
display: block;
}
/* ---------- The split strip: call | book, full width under the masthead ---------- */
.fmh-strip {
display: flex;
border-top: 1px solid var(--fmh-line);
}
.fmh-strip > a {
flex: 1 1 0;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-height: 58px;
padding: 8px 10px;
text-decoration: none;
text-align: center;
}
.fmh-strip-call {
flex-direction: column;
gap: 1px;
color: var(--fmh-ink);
}
.fmh-strip-call + .fmh-strip-book {
border-left: 1px solid var(--fmh-line);
}
.fmh-strip-label {
font-size: 13.5px;
font-weight: 500;
line-height: 1.2;
color: var(--fmh-soft);
}
.fmh-strip-number {
font-family: var(--fmh-font-display);
font-size: clamp(16.5px, 5.2cqi, 19px);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.005em;
font-variant-numeric: lining-nums tabular-nums;
white-space: nowrap;
}
.fmh-strip-call:hover .fmh-strip-number {
text-decoration: underline;
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
}
.fmh-strip-book {
gap: 8px;
background: var(--fmh-accent);
color: var(--fmh-accent-ink);
font-size: 16px;
font-weight: 600;
line-height: 1.25;
}
.fmh-strip-book svg {
flex: none;
transition: transform 180ms ease;
}
.fmh-strip-book:hover {
background: color-mix(in srgb, var(--fmh-accent) 86%, #000);
}
.fmh-strip-book:hover svg {
transform: translateX(3px);
}
/* The page row is a wide-layout element; compact uses the menu. */
.fmh-row {
display: none;
}
/* ---------- Focus ---------- */
.fmh-brand:focus-visible,
.fmh-call:focus-visible,
.fmh-row a:focus-visible,
.fmh-panel a:focus-visible,
.fmh-toggle:focus-visible {
outline: 2px solid var(--fmh-accent);
outline-offset: 3px;
border-radius: 2px;
}
.fmh-strip > a:focus-visible {
outline: 2px solid var(--fmh-ink);
outline-offset: -4px;
}
.fmh-cta:focus-visible {
outline: 2px solid var(--fmh-ink);
outline-offset: 3px;
}
/* The area line joins the name once the masthead has room for it. */
@container (min-width: 600px) {
.fmh-dateline {
display: block;
}
.fmh-panel-note {
display: none;
}
.fmh-mast {
padding-block: 20px;
}
.fmh-toggle {
top: 20px;
}
.fmh-wordmark { font-size: 27px; }
.fmh-wordmark[data-size="m"] { font-size: 24px; }
.fmh-wordmark[data-size="s"] { font-size: 22px; }
.fmh-logo { height: 40px; }
.fmh-strip-number { font-size: 20px; }
.fmh-strip-call {
flex-direction: row;
gap: 10px;
}
.fmh-strip-label {
font-size: 14px;
}
.fmh-panel-links {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 32px;
}
.fmh-panel-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 32px;
}
}
/* ---------- Wide: call | name | book, with the pages under a double rule ---------- */
@container (min-width: 1024px) {
.fmh > * {
--fmh-pad: var(--fmh-grid-gutter, 48px);
}
.fmh-mast {
grid-template-columns: minmax(var(--fmh-flank), 1fr) minmax(0, auto) minmax(var(--fmh-flank), 1fr);
align-items: start;
column-gap: 40px;
padding: 20px var(--fmh-pad) 18px;
}
.fmh-brand {
min-height: 52px;
}
.fmh-flank {
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
}
.fmh-flank--call {
align-items: flex-start;
}
.fmh-flank--book {
align-items: flex-end;
}
.fmh-strip,
.fmh-panel-facts {
display: none;
}
.fmh-wordmark { font-size: 34px; }
.fmh-wordmark[data-size="m"] { font-size: 30px; }
.fmh-wordmark[data-size="s"] { font-size: 27px; }
.fmh-logo { height: 48px; max-width: 260px; }
.fmh-dateline {
margin-top: 0;
font-size: 15px;
}
.fmh-call {
display: inline-flex;
align-items: baseline;
flex-wrap: wrap;
column-gap: 10px;
min-height: 52px;
align-content: center;
color: var(--fmh-ink);
text-decoration: none;
}
.fmh-call-label {
font-size: 15px;
font-weight: 600;
color: var(--fmh-accent);
}
.fmh-call-number {
font-family: var(--fmh-font-display);
font-size: 25px;
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.1;
font-variant-numeric: lining-nums tabular-nums;
white-space: nowrap;
background: linear-gradient(currentColor, currentColor) no-repeat left bottom / 0 2px;
padding-bottom: 3px;
transition: background-size 220ms ease;
}
.fmh-call:hover .fmh-call-number {
background-size: 100% 2px;
}
.fmh-hours {
margin: 0;
font-size: 14.5px;
line-height: 1.45;
color: var(--fmh-soft);
}
.fmh-hours span:not(:last-child)::after {
content: ",";
margin-right: 0.3em;
}
.fmh-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
box-sizing: border-box;
max-width: 100%;
min-height: 52px;
padding: 10px 22px 10px 24px;
border-radius: 2px;
background: var(--fmh-accent);
color: var(--fmh-accent-ink);
font-size: 16px;
font-weight: 600;
line-height: 1.25;
text-align: center;
text-decoration: none;
box-shadow: 0 1px 0 rgb(0 0 0 / 0.08), inset 0 -2px 0 rgb(0 0 0 / 0.14);
transition: background-color 160ms ease;
}
.fmh-cta svg {
flex: none;
transition: transform 180ms ease;
}
.fmh-cta:hover {
background: color-mix(in srgb, var(--fmh-accent) 86%, #000);
}
.fmh-cta:hover svg {
transform: translateX(3px);
}
/* The second tier: the page row, or the centred menu control in its place. */
.fmh-row,
.fmh-menu {
box-sizing: border-box;
max-width: var(--fmh-max);
margin: 0 auto;
padding-inline: var(--fmh-pad);
}
.fmh-row ul,
.fmh-menu > summary {
border-top: 4px double color-mix(in srgb, var(--fmh-ink) 26%, var(--fmh-surface));
}
.fmh-row ul {
display: flex;
justify-content: center;
align-items: center;
gap: 36px;
margin: 0;
padding: 0;
list-style: none;
}
.fmh-row a {
display: inline-flex;
align-items: center;
min-height: 52px;
color: var(--fmh-ink);
font-size: 15.5px;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
background: linear-gradient(currentColor, currentColor) no-repeat center bottom 13px / 0 1.5px;
transition: background-size 200ms ease;
}
.fmh-row a:hover {
background-size: 100% 1.5px;
}
.fmh-row a[aria-current="page"] {
color: var(--fmh-accent);
background-image: linear-gradient(var(--fmh-accent), var(--fmh-accent));
background-size: 100% 2px;
}
.fmh-menu > summary.fmh-toggle {
position: static;
display: flex;
width: 100%;
height: 52px;
border-width: 4px 0 0;
border-radius: 0;
background: transparent;
}
.fmh-menu > summary.fmh-toggle:hover {
color: var(--fmh-accent);
}
.fmh-toggle-text {
position: static;
width: auto;
height: auto;
overflow: visible;
clip-path: none;
}
.fmh-toggle-icon {
width: 14px;
height: 10px;
}
.fmh-panel-links {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
/* The page row replaces the menu from the width that holds it on one line. */
@container (min-width: 1024px) {
.fmh[data-inline="1024"] .fmh-row { display: block; }
.fmh[data-inline="1024"] .fmh-menu { display: none; }
}
@container (min-width: 1180px) {
.fmh[data-inline="1180"] .fmh-row { display: block; }
.fmh[data-inline="1180"] .fmh-menu { display: none; }
}
@container (min-width: 1280px) {
.fmh[data-inline="1280"] .fmh-row { display: block; }
.fmh[data-inline="1280"] .fmh-menu { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.fmh-toggle-icon span,
.fmh-row a,
.fmh-call-number,
.fmh-cta,
.fmh-cta svg,
.fmh-strip-book svg,
.fmh-panel-links a svg {
transition: none;
}
.fmh-cta:hover svg,
.fmh-strip-book:hover svg,
.fmh-panel-links a:hover svg {
transform: none;
}
}
@media (forced-colors: active) {
.fmh-cta,
.fmh-strip-book {
border: 2px solid ButtonText;
}
.fmh-panel-links a[aria-current="page"] {
border-left: 3px solid Highlight;
}
}
</style>
===== END src/components/FlankedMastheadHeader.astro =====
===== FILE (supporting): src/components/FlankedMastheadHeader.md =====
# Flanked masthead header
A centred masthead header: the business name in the middle with the area served beneath it, the number and hours on the left and the booking button on the right as an equal pair, and the pages as one centred row under a double rule. Below 1024px the name stays centred beside a square menu control and the number and booking action become a full-width split call | book strip. Everything renders only when supplied; no script.
## Integration
- Put it first in <body> (after a skip link). In the normal flow; reserves its own height (about 168px on desktop with a page row, 130px on phones with the strip).
- The number renders only from a real `phone.number`; tel: is built from its digits. It is the left flank from 1024px and the left half of the call | book strip below.
- The pages sit in one centred row from a width computed from their labels — never on two lines; below it a centred Menu control (from 1024px) or the square control (below) opens a native <details> list.
- Hours and the note are shown in the masthead on wide screens and in the menu on phones.
- Loads Bricolage Grotesque and Instrument Sans from Google Fonts by default. Pass fontStylesheet="" on a site that loads its own faces, and theme with --fmh-bg, --fmh-text, --fmh-text-muted, --fmh-brand, --fmh-brand-text, --fmh-border, --fmh-family, --fmh-display.
## Provenance
Staged as candidate `flanked-masthead-header` (Step 28) and admitted through the candidate pipeline. Source type: original. References (direction only): none. Quality history: quality/candidates/records/flanked-masthead-header.json.
===== END src/components/FlankedMastheadHeader.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
This record pre-dates source fingerprinting, so it is not tied to a specific revision.
--- 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.
--- 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.