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 "Contents 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: Contents masthead header
Library page: https://astro.baysixmedia.com/components/contents-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 ContentsMastheadHeaderLink[];
currentPath?: string;
phone?: { number: string; label?: string } | null;
email?: string;
hours?: string | readonly string[];
address?: string | readonly string[];
addressHref?: string;
cta?: ContentsMastheadHeaderLink | null;
/** Heading of the contact card in the open menu; "Contact" by default. */
contactLabel?: string;
emailLabel?: string;
hoursLabel?: string;
addressLabel?: string;
navLabel?: string;
menuLabel?: string;
closeLabel?: 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 158px in masthead mode, 134–150px as the two-tier bar; 188px on the narrowest phones when a long action takes its own row).
- The centred masthead and contents rail appear from the narrowest container width where every page and the centred name fit (computed from the labels, the name, the number and the action). Below it the bar keeps two tiers: the pages on the lower tier beside the action where they fit, otherwise a labelled Menu control. Two to seven pages.
- The menu is a native <details> disclosure: keyboard and screen-reader operable, works without JavaScript; the open panel overlays the page start, scrolls within itself and does not lock the page. A small bundled script adds Escape, outside-click, focus-leaving and link-choice close; it boots on astro:page-load (and on first load without ClientRouter), once per header, and releases its listeners on astro:before-swap.
- Number, email, hours, address and the action render only when supplied; the maps link only from a supplied addressHref.
- Loads Newsreader (with italics) and Inter Tight from Google Fonts by default. Pass fontStylesheet="" on a site that loads its own faces; the width budget is calibrated to these faces with a 5% margin.
--- 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/ContentsMastheadHeader.astro =====
---
/**
* ContentsMastheadHeader (candidate, role `navigation`, original)
*
* An editorial masthead in two tiers, at every width.
*
* Where the pages fit, the business's name is set centred in a display serif between two pills of
* equal height — the number (outlined, with a call glyph) on the left and the one action (solid) on
* the right — and under a full-bleed hairline runs the contents rail: every page on one line in the
* serif, hairline dividers between them, the current page italic on an accent rule.
*
* Where they do not fit, the two tiers stay. The top tier carries the name and the number (a 44px
* call button on the narrowest bars, the number itself from 380px, an outlined pill from 600px). The
* lower tier is the same ruled contents line in short form: a labelled Menu control, the current page
* italic on its accent rule where there is room, and the action at the end — so booking is always on
* the first screen. Menu opens the contents as a large serif index beside a contact card (number,
* email, hours, address and the action) that stretches to the index's height.
*
* The switch is computed, not guessed: the frontmatter estimates the width the rail and the centred
* masthead need at the default type and picks the narrowest container width where both fit with a
* margin. Long names and seven long labels therefore never wrap or collide; they keep the index.
*
* Truth rules. Links, phone, email, hours, address and the action each render only when passed;
* the contact card renders only when it has something in it. tel: and mailto: are built from the
* supplied values; a maps link is used only when supplied. The menu is a native <details>
* disclosure, so it works without script; a small script adds Escape, outside-click and link-choice
* close. It boots on astro:page-load, once per header, and releases its listeners on
* astro:before-swap. In the flow; reserves its own height.
*
* Reads, never declares: --cmh-bg, --cmh-text, --cmh-text-muted, --cmh-brand, --cmh-brand-text,
* --cmh-border, --cmh-well, --cmh-family, --cmh-display, --cmh-grid-max, --cmh-grid-gutter.
*/
export interface ContentsMastheadHeaderLink {
label: string;
href: string;
}
interface Props {
brandName?: string;
brandHref?: string;
logo?: { src: string; alt: string; width?: number; height?: number } | null;
items?: readonly ContentsMastheadHeaderLink[];
currentPath?: string;
phone?: { number: string; label?: string } | null;
email?: string;
hours?: string | readonly string[];
address?: string | readonly string[];
addressHref?: string;
cta?: ContentsMastheadHeaderLink | null;
/** Heading of the contact card in the open menu; "Contact" by default. */
contactLabel?: string;
emailLabel?: string;
hoursLabel?: string;
addressLabel?: string;
navLabel?: string;
menuLabel?: string;
closeLabel?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
const {
brandName = "",
brandHref,
logo = null,
items = [],
currentPath,
phone = null,
email = "",
hours,
address,
addressHref,
cta = null,
contactLabel = "Contact",
emailLabel = "Email",
hoursLabel = "Hours",
addressLabel = "Visit",
navLabel = "Primary",
menuLabel = "Menu",
closeLabel = "Close",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&family=Inter+Tight: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 lines = (v: string | readonly string[] | undefined) => (Array.isArray(v) ? v : typeof v === "string" ? [v] : []).map((s) => String(s ?? "").trim()).filter(Boolean);
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() || "Call", href: `tel:${digits}` } : null;
const mail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? email.trim() : "";
const hourLines = lines(hours);
const addressLines = lines(address);
const mapHref = addressLines.length ? safeHref(addressHref) : null;
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 here = links.find((l) => isCurrent(l.href))?.label ?? "";
const hasContact = Boolean(call || mail || hourLines.length || addressLines.length || action);
const hasMenu = links.length > 0 || hasContact;
const toggleText = menuLabel.trim() || "Menu";
const closeText = closeLabel.trim() || "Close";
/*
* The masthead budget, in px at the default faces, calibrated against the rendered rail: Newsreader
* sets about 8.5px a character at 19px and 7.6px at 17px; each page adds its side padding and divider.
* The centred name needs equal room on both sides for the wider of the two pills. A 5% margin covers
* a site face that sets a little wider.
*/
const chars = links.reduce((n, l) => n + l.label.length, 0);
const railW = (size: "lg" | "md") => (size === "lg" ? chars * 8.5 + links.length * 45 : chars * 7.6 + links.length * 34);
const markSize = name.length <= 18 ? 34 : name.length <= 26 ? 30 : name.length <= 34 ? 27 : 24;
const brandW = logo?.src ? Math.min(240, Math.round(((logo.width ?? 160) / (logo.height ?? 40)) * 44)) : name.length * markSize * 0.49;
const callW = call ? call.number.length * 9.2 + 72 : 0;
const ctaW = action ? action.label.length * 8.1 + 54 : 0;
const mastW = brandW + 2 * (Math.max(callW, ctaW) + 28);
const STEPS = [900, 1024, 1180, 1280, 1400] as const;
const pad = (w: number) => (w >= 960 ? 96 : w >= 600 ? 64 : 40);
const barInner = (w: number) => Math.min(w, 1232) - pad(w);
const railInner = (w: number) => Math.min(w, 1440) - pad(w);
const fits = (w: number, size: "lg" | "md") => railInner(w) >= railW(size) * 1.05 && barInner(w) >= mastW * 1.05;
const railAt = links.length ? (STEPS.find((w) => fits(w, "md")) ?? "never") : "never";
const lgAt = links.length ? (STEPS.find((w) => fits(w, "lg")) ?? "never") : "never";
/*
* The short contents line (lower tier of the bar): Menu, the current page where it fits, the action.
* The current page shows from the narrowest width where all three sit on one line; on phones where
* Menu and the action do not share a line, the action takes its own full-width row instead.
*/
const toggleW = toggleText.length * 8.6 + 46;
const hereW = here ? here.length * 7 + 34 : 0;
const pillW = (w: number) => (action ? (w >= 600 ? action.label.length * 7.9 + 50 : action.label.length * 7.4 + 40) : 0);
const HERE_STEPS = [480, 600, 720, 840] as const;
const hereAt = here && hasMenu ? (HERE_STEPS.find((w) => barInner(w) >= (toggleW + hereW + pillW(w) + 28) * 1.05) ?? "never") : "never";
/* Where the whole rail and the action share the lower tier, the pages themselves replace Menu. */
const STRIP_STEPS = [600, 720, 840] as const;
const stripAt = links.length ? (STRIP_STEPS.find((w) => barInner(w) >= (railW("md") - 16 + pillW(w) + 28) * 1.05) ?? "never") : "never";
const strapFits = (w: number) => barInner(w) >= (toggleW + pillW(w) + 14) * 1.04;
const strapStack = !action || !hasMenu ? undefined : !strapFits(380) ? "480" : !strapFits(320) ? "380" : undefined;
/* The compact wordmark: long names set a step smaller and may take two balanced lines on phones. */
const nameSize = name.length <= 14 ? "s" : name.length <= 22 ? "m" : "l";
/* Few pages set larger in the open index. */
const scale = links.length <= 4 ? "lg" : links.length <= 6 ? "md" : "sm";
const uid = id ?? `cmh-${Math.random().toString(36).slice(2, 8)}`;
const panelId = `${uid}-menu`;
const titleId = `${uid}-contact`;
const callName = call ? `${call.label} ${call.number}` : "";
---
{(name || logo?.src) && (
<header class:list={["cmh", className]} id={id} data-cmh-root data-rail={String(railAt)} data-lg={String(lgAt)} data-name={nameSize} data-here={String(hereAt)} data-strip={String(stripAt)}>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{/* Where the pages fit: the centred masthead and the contents rail. */}
{links.length > 0 && (
<div class="cmh-masthead" style={`--cmh-mark:${markSize}px`}>
<div class="cmh-mast-row">
<div class="cmh-mast-start">
{call && (
<a class="cmh-call cmh-call--mast" href={call.href} aria-label={callName}>
<svg class="cmh-glyph" aria-hidden="true" viewBox="0 0 20 20" width="18" height="18"><path d="M6.2 2.8 8 6.6 6.3 8.2c.9 2 2.5 3.6 4.5 4.5l1.6-1.7 3.8 1.8-.6 3.2c-.1.5-.5.8-1 .8C8.4 16.6 3.4 11.6 2.2 5.4c-.1-.5.3-.9.8-1Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" /></svg>
<span class="cmh-call-num">{call.number}</span>
</a>
)}
</div>
{home ? (
<a class="cmh-brand cmh-brand--mast" href={home}>
{logo?.src ? <img class="cmh-logo" src={logo.src} alt={logo.alt || name} width={logo.width} height={logo.height} /> : <span class="cmh-mark">{name}</span>}
</a>
) : (
<span class="cmh-brand cmh-brand--mast">
{logo?.src ? <img class="cmh-logo" src={logo.src} alt={logo.alt || name} width={logo.width} height={logo.height} /> : <span class="cmh-mark">{name}</span>}
</span>
)}
<div class="cmh-mast-end">{action && <a class="cmh-action" href={action.href}>{action.label}</a>}</div>
</div>
<nav class="cmh-rail" 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>
</div>
)}
{/* Where they do not: the name and number above, the short contents line below. */}
<div class="cmh-compact">
<div class="cmh-bar">
{home ? (
<a class="cmh-brand" href={home}>
{logo?.src ? <img class="cmh-logo" src={logo.src} alt={logo.alt || name} width={logo.width} height={logo.height} /> : <span class="cmh-wordmark">{name}</span>}
</a>
) : (
<span class="cmh-brand">
{logo?.src ? <img class="cmh-logo" src={logo.src} alt={logo.alt || name} width={logo.width} height={logo.height} /> : <span class="cmh-wordmark">{name}</span>}
</span>
)}
{call && (
<a class="cmh-call cmh-call--bar" href={call.href} aria-label={callName}>
<svg class="cmh-glyph" aria-hidden="true" viewBox="0 0 20 20" width="18" height="18"><path d="M6.2 2.8 8 6.6 6.3 8.2c.9 2 2.5 3.6 4.5 4.5l1.6-1.7 3.8 1.8-.6 3.2c-.1.5-.5.8-1 .8C8.4 16.6 3.4 11.6 2.2 5.4c-.1-.5.3-.9.8-1Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" /></svg>
<span class="cmh-call-num">{call.number}</span>
</a>
)}
</div>
{(hasMenu || action) && (
<div class="cmh-strap" data-stack={strapStack}>
<div class="cmh-strap-inner">
{hasMenu && (
<details class="cmh-menu" data-cmh-menu>
<summary class="cmh-toggle" aria-controls={panelId}>
<span class="cmh-toggle-mark" aria-hidden="true"></span>
<span class="cmh-toggle-open">{toggleText}</span>
<span class="cmh-toggle-close">{closeText}</span>
</summary>
<div class="cmh-panel" id={panelId}>
<div class="cmh-panel-inner" data-scale={scale} data-solo={links.length === 0 ? "" : undefined}>
{links.length > 0 && (
<nav class="cmh-index" aria-label={navLabel}>
<ul role="list">
{links.map((l) => (
<li>
<a href={l.href} aria-current={isCurrent(l.href) ? "page" : undefined}>
<span class="cmh-index-label">{l.label}</span>
</a>
</li>
))}
</ul>
</nav>
)}
{hasContact && (
<section class="cmh-card" aria-labelledby={titleId}>
<h2 class="cmh-card-title" id={titleId}>{contactLabel}</h2>
{(call || mail || hourLines.length > 0 || addressLines.length > 0) && (
<dl class="cmh-rows">
{call && (
<div class="cmh-row">
<dt>{call.label}</dt>
<dd><a class="cmh-value cmh-value--num" href={call.href}>{call.number}</a></dd>
</div>
)}
{mail && (
<div class="cmh-row">
<dt>{emailLabel}</dt>
<dd><a class="cmh-value" href={`mailto:${mail}`}>{mail}</a></dd>
</div>
)}
{hourLines.length > 0 && (
<div class="cmh-row">
<dt>{hoursLabel}</dt>
<dd class="cmh-lines">{hourLines.map((h) => <span>{h}</span>)}</dd>
</div>
)}
{addressLines.length > 0 && (
<div class="cmh-row">
<dt>{addressLabel}</dt>
<dd class="cmh-lines">
{mapHref ? (
<a class="cmh-value cmh-value--plain" href={mapHref}>{addressLines.map((a) => <span>{a}</span>)}</a>
) : (
addressLines.map((a) => <span>{a}</span>)
)}
</dd>
</div>
)}
</dl>
)}
{action && <a class="cmh-card-cta" href={action.href}>{action.label}</a>}
</section>
)}
</div>
</div>
</details>
)}
{stripAt !== "never" && (
<nav class="cmh-pages" 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>
)}
{here && hasMenu && <span class="cmh-here" aria-hidden="true"><span class="cmh-here-text">{here}</span></span>}
{action && <a class="cmh-action cmh-action--strap" href={action.href}>{action.label}</a>}
</div>
</div>
)}
</div>
</header>
)}
<style>
.cmh {
--cmh-surface: var(--cmh-bg, #f5f3ee);
--cmh-ink: var(--cmh-text, #16171a);
--cmh-soft: var(--cmh-text-muted, #46484d);
--cmh-accent: var(--cmh-brand, #8a3a24);
--cmh-accent-ink: var(--cmh-brand-text, #ffffff);
--cmh-line-c: var(--cmh-border, #d6d0c3);
--cmh-card-bg: var(--cmh-well, #ebe6db);
--cmh-font: var(--cmh-family, "Inter Tight", ui-sans-serif, system-ui, sans-serif);
--cmh-serif: var(--cmh-display, "Newsreader", ui-serif, Georgia, serif);
--cmh-max: var(--cmh-grid-max, 1232px);
--cmh-pad: 20px;
container-type: inline-size;
position: relative;
z-index: 40;
background: var(--cmh-surface);
color: var(--cmh-ink);
font-family: var(--cmh-font);
border-bottom: 1px solid var(--cmh-line-c);
}
.cmh *,
.cmh *::before,
.cmh *::after {
box-sizing: border-box;
}
.cmh ul,
.cmh dl,
.cmh dd {
margin: 0;
padding: 0;
list-style: none;
}
.cmh a:focus-visible,
.cmh-toggle:focus-visible {
outline: 2px solid var(--cmh-accent);
outline-offset: 3px;
border-radius: 3px;
}
.cmh .cmh-rail a:focus-visible,
.cmh .cmh-pages a:focus-visible {
outline-offset: -4px;
}
.cmh .cmh-action:focus-visible,
.cmh .cmh-card-cta:focus-visible,
.cmh .cmh-call:focus-visible {
border-radius: 999px;
}
/* Shared pieces */
.cmh-brand {
display: inline-flex;
align-items: center;
min-height: 44px;
min-width: 0;
color: inherit;
text-decoration: none;
}
.cmh-logo {
display: block;
height: 34px;
width: auto;
max-width: 200px;
object-fit: contain;
}
/* The number: an outlined pill the same height as the action, so the two flanks weigh the same. */
.cmh-call {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
flex: none;
min-height: 48px;
padding: 0 22px 0 18px;
border: 1.5px solid var(--cmh-ink);
border-radius: 999px;
color: var(--cmh-ink);
text-decoration: none;
white-space: nowrap;
transition: background-color 180ms ease, color 180ms ease, border-color 180ms ease;
}
.cmh-glyph {
flex: none;
color: var(--cmh-accent);
transition: color 180ms ease;
}
.cmh-call-num {
font-size: 16px;
font-weight: 600;
line-height: 1.2;
letter-spacing: 0.005em;
font-variant-numeric: tabular-nums;
}
.cmh-call:hover {
background: var(--cmh-ink);
color: var(--cmh-surface);
}
.cmh-call:hover .cmh-glyph {
color: currentColor;
}
.cmh-action {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
min-height: 48px;
padding: 0 24px;
border: 1.5px solid var(--cmh-ink);
border-radius: 999px;
background: var(--cmh-ink);
color: var(--cmh-surface);
font-size: 15px;
font-weight: 600;
line-height: 1.2;
text-align: center;
text-decoration: none;
white-space: nowrap;
transition: background-color 180ms ease, border-color 180ms ease, color 180ms ease;
}
.cmh-action:hover {
background: var(--cmh-accent);
border-color: var(--cmh-accent);
color: var(--cmh-accent-ink);
}
/* ---------- The masthead (shown only where the computed width allows) ---------- */
.cmh-masthead {
display: none;
padding-inline: var(--cmh-pad);
}
.cmh-mast-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 28px;
max-width: calc(var(--cmh-max) - 2 * var(--cmh-pad));
min-height: 100px;
margin: 0 auto;
}
.cmh-mast-start {
justify-self: start;
}
.cmh-mast-end {
justify-self: end;
}
.cmh-brand--mast {
justify-self: center;
text-align: center;
}
.cmh-brand--mast .cmh-logo {
height: 44px;
max-width: 240px;
}
.cmh-mark {
font-family: var(--cmh-serif);
font-size: var(--cmh-mark, 30px);
font-weight: 500;
letter-spacing: -0.02em;
line-height: 1;
white-space: nowrap;
font-variation-settings: "opsz" 60;
/* Centre the capitals, not the line box: the serif's descender space otherwise lifts the name. */
text-box: trim-both cap alphabetic;
}
/* The contents rail: every page on one ruled line under the name. */
.cmh-rail {
border-top: 1px solid var(--cmh-line-c);
margin-inline: calc(-1 * var(--cmh-pad));
}
.cmh-rail ul {
display: flex;
justify-content: center;
align-items: stretch;
max-width: 1440px;
margin: 0 auto;
padding-inline: var(--cmh-pad);
}
.cmh-rail li {
position: relative;
display: flex;
}
.cmh-rail li + li::before,
.cmh-pages li + li::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 1px;
height: 20px;
margin-top: -10px;
background: var(--cmh-line-c);
}
.cmh-rail a,
.cmh-pages a {
position: relative;
display: inline-flex;
align-items: center;
min-height: 56px;
padding: 0 16px;
color: var(--cmh-ink);
font-family: var(--cmh-serif);
font-size: 17px;
line-height: 1.25;
white-space: nowrap;
text-decoration: none;
transition: color 180ms ease;
}
.cmh-rail a::after,
.cmh-pages a::after {
content: "";
position: absolute;
left: 16px;
right: 16px;
bottom: -1px;
height: 2px;
background: var(--cmh-accent);
transform: scaleX(0);
transform-origin: left center;
transition: transform 240ms cubic-bezier(0.2, 0.7, 0.2, 1);
}
.cmh-rail a:hover,
.cmh-pages a:hover {
color: var(--cmh-accent);
}
.cmh-rail a:hover::after,
.cmh-rail a[aria-current="page"]::after,
.cmh-pages a:hover::after,
.cmh-pages a[aria-current="page"]::after {
transform: scaleX(1);
}
.cmh-rail a[aria-current="page"],
.cmh-pages a[aria-current="page"] {
font-style: italic;
color: var(--cmh-accent);
}
/* ---------- The compact masthead: two tiers ---------- */
.cmh-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
max-width: var(--cmh-max);
min-height: 72px;
margin: 0 auto;
padding-block: 10px;
padding-inline: var(--cmh-pad);
}
.cmh-wordmark {
display: block;
font-family: var(--cmh-serif);
font-size: 24px;
font-weight: 500;
letter-spacing: -0.02em;
line-height: 1.04;
text-wrap: balance;
overflow-wrap: anywhere;
font-variation-settings: "opsz" 48;
text-box: trim-both cap alphabetic;
}
/* Phones: a long name takes two balanced lines rather than shrinking out of scale. */
.cmh[data-name="m"] .cmh-wordmark,
.cmh[data-name="l"] .cmh-wordmark {
font-size: 22px;
}
/* The narrowest bars: the number as a 44px call button. */
.cmh-call--bar {
width: 44px;
min-height: 44px;
height: 44px;
padding: 0;
border-width: 1px;
border-color: var(--cmh-line-c);
}
.cmh-call--bar .cmh-call-num {
display: none;
}
/* The short contents line: the rail's rule, Menu, the current page and the action. */
.cmh-strap {
border-top: 1px solid var(--cmh-line-c);
}
.cmh-strap-inner {
display: flex;
align-items: center;
flex-wrap: wrap;
column-gap: 14px;
row-gap: 10px;
max-width: var(--cmh-max);
min-height: 60px;
margin: 0 auto;
padding-inline: var(--cmh-pad);
}
.cmh-action--strap {
margin-inline-start: auto;
min-height: 44px;
padding: 0 18px;
font-size: 14.5px;
}
.cmh-here {
position: relative;
display: none;
align-items: center;
align-self: stretch;
min-width: 0;
padding-inline: 14px 2px;
font-family: var(--cmh-serif);
font-size: 18px;
font-style: italic;
line-height: 1.2;
white-space: nowrap;
color: var(--cmh-accent);
}
.cmh-here-text {
display: block;
/* Centre on the letters, as the Menu label beside it is. */
text-box: trim-both cap alphabetic;
}
.cmh-here::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 1px;
height: 20px;
margin-top: -10px;
background: var(--cmh-line-c);
}
.cmh-here::after {
content: "";
position: absolute;
left: 14px;
right: 2px;
bottom: -1px;
height: 2px;
background: var(--cmh-accent);
}
/* The pages on the lower tier: the rail, set from the start edge under the name. */
.cmh-pages {
display: none;
align-self: stretch;
min-width: 0;
}
.cmh-pages ul {
display: flex;
align-items: stretch;
height: 100%;
}
.cmh-pages li {
position: relative;
display: flex;
}
.cmh-pages li:first-child a {
padding-inline-start: 0;
}
.cmh-pages li:first-child a::after {
left: 0;
}
.cmh-menu {
align-self: stretch;
display: flex;
}
.cmh-toggle {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 0 6px 0 0;
cursor: pointer;
list-style: none;
font-size: 16px;
font-weight: 600;
user-select: none;
}
.cmh-toggle::-webkit-details-marker {
display: none;
}
.cmh-toggle-close {
display: none;
}
.cmh-menu[open] .cmh-toggle-open {
display: none;
}
.cmh-menu[open] .cmh-toggle-close {
display: inline;
}
.cmh-toggle-mark {
position: relative;
flex: none;
width: 22px;
height: 12px;
}
.cmh-toggle-mark::before,
.cmh-toggle-mark::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 1.5px;
background: currentColor;
transition: transform 220ms ease, top 220ms ease;
}
.cmh-toggle-mark::before {
top: 2px;
}
.cmh-toggle-mark::after {
top: 9px;
}
.cmh-menu[open] .cmh-toggle-mark::before {
top: 5.5px;
transform: rotate(35deg);
}
.cmh-menu[open] .cmh-toggle-mark::after {
top: 5.5px;
transform: rotate(-35deg);
}
/* The index */
.cmh-panel {
position: absolute;
left: 0;
right: 0;
top: 100%;
max-height: calc(100dvh - 132px);
overflow-y: auto;
overscroll-behavior: contain;
background: var(--cmh-surface);
border-top: 1px solid var(--cmh-line-c);
border-bottom: 1px solid var(--cmh-line-c);
box-shadow: 0 36px 48px -40px rgb(22 23 26 / 0.4);
}
.cmh-panel-inner {
display: grid;
gap: 28px;
max-width: var(--cmh-max);
margin: 0 auto;
padding: 8px var(--cmh-pad) 28px;
}
.cmh-index li {
border-bottom: 1px solid var(--cmh-line-c);
}
.cmh-index a {
display: flex;
align-items: baseline;
min-height: 60px;
padding-block: 14px 12px;
color: var(--cmh-ink);
font-family: var(--cmh-serif);
font-size: 28px;
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.1;
text-decoration: none;
transition: color 180ms ease;
}
.cmh-panel-inner[data-scale="sm"] .cmh-index a {
font-size: 25px;
min-height: 54px;
padding-block: 12px 10px;
}
.cmh-index-label {
transition: transform 240ms cubic-bezier(0.2, 0.7, 0.2, 1);
}
.cmh-index a:hover {
color: var(--cmh-accent);
}
.cmh-index a:hover .cmh-index-label {
transform: translateX(6px);
}
.cmh-index a[aria-current="page"] {
color: var(--cmh-accent);
}
.cmh-index a[aria-current="page"] .cmh-index-label {
font-style: italic;
}
/* The contact card: the same ruled rows as the index, on a quiet well. */
.cmh-card {
display: flex;
flex-direction: column;
padding: 22px 20px 20px;
border-radius: 4px;
background: var(--cmh-card-bg);
}
.cmh-card-title {
margin: 0 0 12px;
font-family: var(--cmh-serif);
font-size: 24px;
font-weight: 500;
letter-spacing: -0.015em;
line-height: 1.1;
}
.cmh-row {
display: grid;
grid-template-columns: 62px minmax(0, 1fr);
align-items: baseline;
gap: 12px;
padding-block: 4px;
border-bottom: 1px solid var(--cmh-line-c);
}
.cmh-row dd.cmh-lines {
padding-block: 9px;
}
.cmh-row dt {
font-family: var(--cmh-serif);
font-style: italic;
font-size: 17px;
color: var(--cmh-soft);
}
.cmh-row dd {
font-size: 17px;
font-weight: 500;
line-height: 1.4;
overflow-wrap: break-word;
}
.cmh-value {
display: inline-flex;
align-items: center;
min-height: 44px;
color: var(--cmh-ink);
text-decoration: underline;
text-decoration-color: var(--cmh-line-c);
text-decoration-thickness: 1px;
text-underline-offset: 5px;
transition: text-decoration-color 180ms ease;
}
.cmh-value:hover {
text-decoration-color: currentColor;
}
.cmh-value--num {
white-space: nowrap;
font-size: 19px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.cmh-lines,
.cmh-value--plain {
display: grid;
}
.cmh-value--plain {
align-items: start;
}
.cmh-lines {
font-weight: 500;
}
.cmh-card-cta {
display: flex;
align-items: center;
justify-content: center;
min-height: 52px;
margin-top: 20px;
padding: 0 24px;
border-radius: 999px;
background: var(--cmh-ink);
color: var(--cmh-surface);
font-size: 16px;
font-weight: 600;
text-align: center;
text-decoration: none;
transition: background-color 180ms ease, color 180ms ease;
}
.cmh-card-cta:hover {
background: var(--cmh-accent);
color: var(--cmh-accent-ink);
}
@container (max-width: 379px) {
.cmh-bar {
gap: 10px;
}
.cmh-wordmark,
.cmh[data-name] .cmh-wordmark {
font-size: 20px;
}
}
/* Phones: an action too long to share the line with Menu takes its own full-width row. */
@container (max-width: 379px) {
.cmh-strap[data-stack] .cmh-strap-inner {
padding-block: 4px 12px;
}
.cmh-strap[data-stack] .cmh-action--strap {
flex: 1 0 100%;
margin-inline-start: 0;
}
}
@container (max-width: 479px) {
.cmh-strap[data-stack="480"] .cmh-strap-inner {
padding-block: 4px 12px;
}
.cmh-strap[data-stack="480"] .cmh-action--strap {
flex: 1 0 100%;
margin-inline-start: 0;
}
}
/* From 380px the number itself sits beside the name. */
@container (min-width: 380px) {
.cmh-call--bar {
width: auto;
padding: 0 2px 0 4px;
border: 0;
}
.cmh-call--bar .cmh-call-num {
display: inline;
font-size: 16.5px;
}
.cmh-call--bar:hover {
background: none;
color: var(--cmh-ink);
}
.cmh-call--bar:hover .cmh-call-num {
text-decoration: underline;
text-decoration-thickness: 1.5px;
text-underline-offset: 5px;
}
.cmh-call--bar:hover .cmh-glyph {
color: var(--cmh-accent);
}
}
@container (min-width: 600px) {
.cmh {
--cmh-pad: 32px;
}
.cmh-bar {
min-height: 84px;
gap: 20px;
}
.cmh-wordmark,
.cmh[data-name] .cmh-wordmark {
font-size: 28px;
line-height: 1.02;
}
.cmh[data-name="l"] .cmh-wordmark {
font-size: 25px;
}
.cmh-logo {
height: 38px;
}
/* The number becomes the same outlined pill as on the masthead. */
.cmh-call--bar,
.cmh-call--bar:hover {
padding: 0 20px 0 16px;
border: 1.5px solid var(--cmh-ink);
}
.cmh-call--bar .cmh-call-num {
font-size: 16px;
}
.cmh-call--bar:hover {
background: var(--cmh-ink);
color: var(--cmh-surface);
}
.cmh-call--bar:hover .cmh-call-num {
text-decoration: none;
}
.cmh-call--bar:hover .cmh-glyph {
color: currentColor;
}
.cmh-strap-inner {
min-height: 64px;
column-gap: 18px;
}
.cmh-here {
padding-inline-start: 18px;
}
.cmh-here::after {
left: 18px;
}
.cmh-action--strap {
min-height: 46px;
padding: 0 22px;
font-size: 15px;
}
.cmh-panel {
max-height: calc(100dvh - 150px);
}
}
@container (min-width: 720px) {
.cmh-panel-inner {
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
column-gap: 48px;
padding-block: 20px 40px;
}
.cmh-panel-inner[data-solo] {
grid-template-columns: minmax(0, 1fr);
max-width: 640px;
margin-inline: auto;
}
.cmh-index a {
font-size: 38px;
min-height: 72px;
padding-block: 18px 14px;
}
.cmh-panel-inner[data-scale="lg"] .cmh-index a {
font-size: 46px;
min-height: 84px;
}
.cmh-panel-inner[data-scale="sm"] .cmh-index a {
font-size: 32px;
min-height: 62px;
padding-block: 14px 12px;
}
.cmh-card {
align-self: stretch;
margin-top: 12px;
padding: 28px 28px 28px;
}
.cmh-card-cta {
margin-top: auto;
}
.cmh .cmh-rows {
margin-bottom: 28px;
}
/* The card's column is narrow here: each label sits above its value. */
.cmh-row {
grid-template-columns: minmax(0, 1fr);
gap: 0;
padding-block: 10px 4px;
}
.cmh-row dd.cmh-lines {
padding-block: 2px 8px;
}
/* The number leads the card in the display face. */
.cmh-value--num {
font-family: var(--cmh-serif);
font-size: 27px;
font-weight: 500;
letter-spacing: -0.01em;
font-variant-numeric: lining-nums;
}
}
@container (min-width: 960px) {
.cmh {
--cmh-pad: var(--cmh-grid-gutter, 48px);
}
}
@container (min-width: 1024px) {
.cmh-row {
grid-template-columns: 76px minmax(0, 1fr);
gap: 12px;
padding-block: 4px;
}
.cmh-row dd.cmh-lines {
padding-block: 9px;
}
.cmh-value--num {
font-size: 30px;
}
}
/* The current page joins the short contents line from the width the frontmatter computed. */
@container (min-width: 480px) {
.cmh[data-here="480"] .cmh-here { display: inline-flex; }
}
@container (min-width: 600px) {
.cmh[data-here="600"] .cmh-here { display: inline-flex; }
}
@container (min-width: 720px) {
.cmh[data-here="720"] .cmh-here { display: inline-flex; }
}
@container (min-width: 840px) {
.cmh[data-here="840"] .cmh-here { display: inline-flex; }
}
/* The pages replace Menu on the lower tier from the width the frontmatter computed. */
@container (min-width: 600px) {
.cmh[data-strip="600"] .cmh-pages { display: block; }
.cmh[data-strip="600"] .cmh-menu,
.cmh[data-strip="600"] .cmh-here { display: none; }
}
@container (min-width: 720px) {
.cmh[data-strip="720"] .cmh-pages { display: block; }
.cmh[data-strip="720"] .cmh-menu,
.cmh[data-strip="720"] .cmh-here { display: none; }
}
@container (min-width: 840px) {
.cmh[data-strip="840"] .cmh-pages { display: block; }
.cmh[data-strip="840"] .cmh-menu,
.cmh[data-strip="840"] .cmh-here { display: none; }
}
/* The masthead replaces the two-tier bar from the width the frontmatter computed. */
@container (min-width: 900px) {
.cmh[data-rail="900"] .cmh-masthead { display: block; }
.cmh[data-rail="900"] .cmh-compact { display: none; }
.cmh[data-lg="900"] .cmh-rail a { font-size: 19px; padding-inline: 22px; }
.cmh[data-lg="900"] .cmh-rail a::after { left: 22px; right: 22px; }
}
@container (min-width: 1024px) {
.cmh[data-rail="1024"] .cmh-masthead { display: block; }
.cmh[data-rail="1024"] .cmh-compact { display: none; }
.cmh[data-lg="1024"] .cmh-rail a { font-size: 19px; padding-inline: 22px; }
.cmh[data-lg="1024"] .cmh-rail a::after { left: 22px; right: 22px; }
}
@container (min-width: 1180px) {
.cmh[data-rail="1180"] .cmh-masthead { display: block; }
.cmh[data-rail="1180"] .cmh-compact { display: none; }
.cmh[data-lg="1180"] .cmh-rail a { font-size: 19px; padding-inline: 22px; }
.cmh[data-lg="1180"] .cmh-rail a::after { left: 22px; right: 22px; }
}
@container (min-width: 1280px) {
.cmh[data-rail="1280"] .cmh-masthead { display: block; }
.cmh[data-rail="1280"] .cmh-compact { display: none; }
.cmh[data-lg="1280"] .cmh-rail a { font-size: 19px; padding-inline: 22px; }
.cmh[data-lg="1280"] .cmh-rail a::after { left: 22px; right: 22px; }
}
@container (min-width: 1400px) {
.cmh[data-rail="1400"] .cmh-masthead { display: block; }
.cmh[data-rail="1400"] .cmh-compact { display: none; }
.cmh[data-lg="1400"] .cmh-rail a { font-size: 19px; padding-inline: 22px; }
.cmh[data-lg="1400"] .cmh-rail a::after { left: 22px; right: 22px; }
}
@media (prefers-reduced-motion: reduce) {
.cmh-action,
.cmh-call,
.cmh-glyph,
.cmh-card-cta,
.cmh-toggle-mark::before,
.cmh-toggle-mark::after,
.cmh-rail a,
.cmh-rail a::after,
.cmh-pages a,
.cmh-pages a::after,
.cmh-index a,
.cmh-index-label,
.cmh-value {
transition: none;
}
.cmh-index a:hover .cmh-index-label {
transform: none;
}
}
@media (forced-colors: active) {
.cmh-action,
.cmh-call,
.cmh-card-cta {
border: 2px solid ButtonText;
}
.cmh-toggle-mark::before,
.cmh-toggle-mark::after,
.cmh-rail a::after,
.cmh-rail li + li::before,
.cmh-pages a::after,
.cmh-pages li + li::before,
.cmh-here::before,
.cmh-here::after {
background: CanvasText;
}
.cmh-card {
border: 1px solid CanvasText;
}
}
</style>
<script>
/*
* Adds Escape, outside-click and link-choice close to the native disclosure. One controller per
* header, safe to run again after a client-side navigation; listeners are released on swap.
*/
function initContentsMastheads() {
for (const root of document.querySelectorAll<HTMLElement>("[data-cmh-root]:not([data-cmh-ready])")) {
const menu = root.querySelector<HTMLDetailsElement>("[data-cmh-menu]");
const summary = menu?.querySelector<HTMLElement>("summary");
if (!menu || !summary) continue;
root.setAttribute("data-cmh-ready", "");
const ac = new AbortController();
const { signal } = ac;
document.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape" || !menu.open) return;
event.preventDefault();
menu.open = false;
summary.focus();
},
{ signal },
);
document.addEventListener(
"pointerdown",
(event) => {
if (menu.open && !menu.contains(event.target as Node)) menu.open = false;
},
{ signal },
);
/* Keyboard users tabbing out of the open index close it too. */
menu.addEventListener(
"focusout",
(event) => {
const next = event.relatedTarget as Node | null;
if (menu.open && next && !menu.contains(next)) menu.open = false;
},
{ signal },
);
menu.querySelector(".cmh-panel")?.addEventListener(
"click",
(event) => {
if ((event.target as HTMLElement).closest("a")) menu.open = false;
},
{ signal },
);
document.addEventListener(
"astro:before-swap",
() => {
ac.abort();
root.removeAttribute("data-cmh-ready");
},
{ once: true },
);
}
}
initContentsMastheads();
document.addEventListener("astro:page-load", initContentsMastheads);
</script>
===== END src/components/ContentsMastheadHeader.astro =====
===== FILE (supporting): src/components/ContentsMastheadHeader.md =====
# Contents masthead header
A two-tier editorial masthead: the name centred in a display serif between an outlined call pill and one solid action, with every page on a ruled serif contents line beneath. Narrower, it keeps both tiers — the pages or a labelled Menu share the contents line with the action — and Menu opens a large serif index beside a contact card. Computed switch widths; a small script adds Escape and outside-click close.
## Integration
- Put it first in <body> (after a skip link). In the normal flow; reserves its own height (about 158px in masthead mode, 134–150px as the two-tier bar; 188px on the narrowest phones when a long action takes its own row).
- The centred masthead and contents rail appear from the narrowest container width where every page and the centred name fit (computed from the labels, the name, the number and the action). Below it the bar keeps two tiers: the pages on the lower tier beside the action where they fit, otherwise a labelled Menu control. Two to seven pages.
- The menu is a native <details> disclosure: keyboard and screen-reader operable, works without JavaScript; the open panel overlays the page start, scrolls within itself and does not lock the page. A small bundled script adds Escape, outside-click, focus-leaving and link-choice close; it boots on astro:page-load (and on first load without ClientRouter), once per header, and releases its listeners on astro:before-swap.
- Number, email, hours, address and the action render only when supplied; the maps link only from a supplied addressHref.
- Loads Newsreader (with italics) and Inter Tight from Google Fonts by default. Pass fontStylesheet="" on a site that loads its own faces; the width budget is calibrated to these faces with a 5% margin.
## Provenance
Staged as candidate `contents-masthead-header` (Step 28) and admitted through the candidate pipeline. Source type: original. References (direction only): https://www.sollishealth.com/, https://www.hassellstudio.com/, https://www.kpf.com/. Quality history: quality/candidates/records/contents-masthead-header.json.
===== END src/components/ContentsMastheadHeader.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.