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 "Ruled fascia 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: Ruled fascia header
Library page: https://astro.baysixmedia.com/components/ruled-fascia-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 RuledFasciaHeaderLink[];
currentPath?: string;
phone?: { number: string; label?: string } | null;
cta?: RuledFasciaHeaderLink | null;
plate?: "ink" | "paper";
navLabel?: string;
menuNavLabel?: 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). It is in the normal flow and reserves its own height: nothing to offset.
- Pages come from `items`; the width at which they sit inline is computed from the name, the labels, the number and the action, so they never wrap — below it they are in the menu. A small script re-measures in the loaded face and only ever steps down (pages to the menu, then the number to its call cell).
- The number stays in the band at every width: written out where it fits, a 56–64px call cell elsewhere (its accessible name always includes the number).
- The menu is a native <details> disclosure and works without JavaScript; the script adds Escape, outside click and link choice. It re-initialises on astro:page-load and releases its listeners on astro:before-swap.
- Aligns to the site grid through --rfh-grid-max / --rfh-grid-gutter: the nameplate text starts and the action text ends on those content edges; the plate and the action colour bleed to the screen edges.
- With a logo drawn for a light ground the nameplate turns paper-toned automatically; pass plate="ink" for a light logo on the solid plate.
- Loads Bricolage Grotesque from Google Fonts by default. Pass fontStylesheet="" on a site that loads its own faces (via --rfh-family / --rfh-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/RuledFasciaHeader.astro =====
---
/**
* RuledFasciaHeader (candidate, original, role `navigation`)
*
* A header built like a shopfront fascia: one full-bleed band divided into ruled compartments.
* The name sits on a solid nameplate that runs from the left edge of the screen; the pages are a
* row of equal ruled cells; the number has its own cell (a small "Call" line over the number set
* large); the one action is a filled block that runs out to the right edge. Every cell is the full
* height of the bar, so the rules — not loose gaps — space the controls, at every width.
*
* The nameplate text starts, and the action text ends, exactly on the site grid's content edges;
* only their colour bleeds past them.
*
* It never wraps. The frontmatter estimates the one-line width of the name, every label, the
* number and the action, and picks the narrowest container width at which they fit with a margin;
* below it the pages move into a menu. The number is written out wherever it fits and is a 44px+
* call cell everywhere else: it never leaves the bar. A small script then measures the real bar in
* the face the page loaded and steps down (pages to the menu, then the number to its icon) if the
* estimate was short; without JavaScript the estimate stands.
*
* Truth rules. Pages, number and action render only when passed. The call cell needs a real number
* (tel: built from its digits). No links, no number, no action: the band is the nameplate only.
*
* The menu is a native <details> disclosure (works without JavaScript); the script adds Escape,
* close on outside click and close on choosing a link. ClientRouter-safe: it runs on
* astro:page-load, once per header, and releases its listeners on astro:before-swap.
*
* Reads, never declares: --rfh-bg, --rfh-text, --rfh-tint, --rfh-rule, --rfh-plate,
* --rfh-plate-text, --rfh-brand, --rfh-brand-hover, --rfh-brand-text, --rfh-family, --rfh-display,
* --rfh-grid-max, --rfh-grid-gutter.
*/
export interface RuledFasciaHeaderLink {
label: string;
href: string;
}
interface Props {
brandName?: string;
brandHref?: string;
logo?: { src: string; alt: string; width?: number; height?: number } | null;
items?: readonly RuledFasciaHeaderLink[];
currentPath?: string;
phone?: { number: string; label?: string } | null;
cta?: RuledFasciaHeaderLink | null;
plate?: "ink" | "paper";
navLabel?: string;
menuNavLabel?: string;
menuLabel?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
const {
brandName = "",
brandHref,
logo = null,
items = [],
currentPath,
phone = null,
cta = null,
plate,
navLabel = "Primary",
menuNavLabel = "Site menu",
menuLabel = "Menu",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wdth,wght@12..96,75..100,400..800&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() || "Call", href: `tel:${digits}` }
: null;
const name = brandName.trim();
const home = safeHref(brandHref);
const logoSrc = logo?.src?.trim() ? logo : null;
const norm = (p: string) => p.replace(/\/+$/, "") || "/";
const isCurrent = (href: string) => (currentPath ? norm(currentPath) === norm(href) : false);
/* A supplied logo is usually drawn for a light ground, so the nameplate turns paper unless told otherwise. */
const plateTone = plate ?? (logoSrc ? "paper" : "ink");
/* The wordmark steps down by the name's length so it holds one line on the plate. */
const nameSize = name.length <= 16 ? "l" : name.length <= 24 ? "m" : "s";
const menuText = menuLabel.trim() || "Menu";
/*
* The one-line budget, in px at the default type, calibrated against the rendered band (Bricolage
* Grotesque): per-character widths for the wordmark at each step, the 15.5px page labels, the 17px
* number, the 12.5px call line and the 15.5px action. Cell paddings and 1px rules are added as drawn.
* A 3% margin covers a site face that sets a little wider; the script corrects anything beyond that.
*/
const K = { brand: { l: 9.6, m: 8.6, s: 7.8 }, link: 7.8, num: 8.9, label: 6.2, cta: 7.3, menu: 7.8 };
const brandW = logoSrc ? Math.min(200, Math.round(((logoSrc.width ?? 160) / (logoSrc.height ?? 40)) * 36)) : Math.max(56, name.length * K.brand[nameSize]);
const plateW = brandW + 32;
const navW = links.reduce((w, l) => w + l.label.length * K.link + 40 + 1, 0);
const callFullW = call ? Math.max(call.label.length * K.label, call.number.length * K.num) + 20 + 12 + 40 + 1 : 0;
const ctaW = action ? action.label.length * K.cta + 24 + 26 : 0;
const menuW = links.length ? menuText.length * K.menu + 18 + 10 + 24 + 1 : 0;
/* The content width a container leaves between the grid's edges: 24px gutters below 960, the site gutter (48) from 960, 1232 max. */
const inner = (w: number) => Math.min(w, 1232) - 2 * (w >= 960 ? 48 : 24);
const fits = (need: number, w: number) => need * 1.03 <= inner(w);
/* Pages inline: nameplate + page cells + the written-out number + the action. */
const INLINE_STEPS = [760, 860, 960, 1080, 1180, 1280, 1400] as const;
const inlineAt = links.length ? (INLINE_STEPS.find((w) => fits(plateW + navW + callFullW + ctaW, w)) ?? "never") : "0";
/* Pages in the menu: the number is written out from the first width at which it fits beside the action and the menu cell. */
const NUMBER_STEPS = [600, 640, 680, 720, 740, 780, 860, 960, 1080, 1180] as const;
const numberAt = call ? (NUMBER_STEPS.find((w) => fits(plateW + callFullW + (action ? ctaW + 24 : 0) + menuW, w)) ?? "never") : "never";
/* Which cell closes the band when the menu is not showing: it takes the grid edge as its right padding. */
const last = action ? "cta" : call ? "call" : links.length ? "nav" : "none";
const panelId = `${id ?? "rfh"}-menu`;
const hasBand = Boolean(name || logoSrc || links.length);
---
{hasBand && (
<header
class:list={["rfh", className]}
id={id}
data-rfh-root
data-inline={String(inlineAt)}
data-number={String(numberAt)}
data-last={last}
data-plate={plateTone}
data-name={nameSize}
data-links={String(links.length)}
>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="rfh-bar">
{home ? (
<a class="rfh-brand" href={home}>
{logoSrc ? <img class="rfh-logo" src={logoSrc.src} alt={logoSrc.alt || name} width={logoSrc.width} height={logoSrc.height} /> : <span class="rfh-wordmark">{name}</span>}
</a>
) : (
<span class="rfh-brand">
{logoSrc ? <img class="rfh-logo" src={logoSrc.src} alt={logoSrc.alt || name} width={logoSrc.width} height={logoSrc.height} /> : <span class="rfh-wordmark">{name}</span>}
</span>
)}
{links.length > 0 && (
<nav class="rfh-nav" aria-label={navLabel}>
<ul role="list">
{links.map((l) => (
<li>
<a class="rfh-link" href={l.href} aria-current={isCurrent(l.href) ? "page" : undefined}>{l.label}</a>
</li>
))}
</ul>
</nav>
)}
<span class="rfh-spacer" aria-hidden="true"></span>
{call && (
<a class="rfh-call" href={call.href}>
<svg class="rfh-call-icon" aria-hidden="true" viewBox="0 0 20 20" width="20" height="20"><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="rfh-call-text">
<span class="rfh-call-label">{call.label}</span>{" "}
<span class="rfh-call-number">{call.number}</span>
</span>
</a>
)}
{action && (
<a class="rfh-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" stroke-linecap="round" stroke-linejoin="round" /></svg>
</a>
)}
{links.length > 0 && (
<details class="rfh-menu" data-rfh-menu>
<summary class="rfh-toggle" aria-controls={panelId}>
<span class="rfh-toggle-text">{menuText}</span>
<span class="rfh-toggle-icon" aria-hidden="true"><span></span><span></span></span>
</summary>
<div class="rfh-panel" id={panelId}>
<div class="rfh-panel-inner">
<nav aria-label={menuNavLabel}>
<ul role="list" class="rfh-panel-links" data-count={String(links.length)}>
{links.map((l) => (
<li>
<a href={l.href} aria-current={isCurrent(l.href) ? "page" : undefined}>{l.label}</a>
</li>
))}
</ul>
</nav>
{(call || action) && (
<div class="rfh-panel-actions">
{action && (
<a class="rfh-panel-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" stroke-linecap="round" stroke-linejoin="round" /></svg>
</a>
)}
{call && (
<a class="rfh-panel-call" href={call.href}>
<span class="rfh-call-label">{call.label}</span>{" "}
<span class="rfh-call-number">{call.number}</span>
</a>
)}
</div>
)}
</div>
</div>
</details>
)}
</div>
</header>
)}
<style>
.rfh {
--rfh-paper: var(--rfh-bg, #f2ede3);
--rfh-ink: var(--rfh-text, #1a1813);
--rfh-hover: var(--rfh-tint, #e6decd);
--rfh-line: var(--rfh-rule, #1a1813);
--rfh-plate-bg: var(--rfh-plate, #1f3b33);
--rfh-plate-ink: var(--rfh-plate-text, #f5efe2);
--rfh-accent: var(--rfh-brand, #a93d17);
--rfh-accent-deep: var(--rfh-brand-hover, #8f3212);
--rfh-accent-ink: var(--rfh-brand-text, #fff8ef);
--rfh-font: var(--rfh-family, "Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif);
--rfh-font-display: var(--rfh-display, "Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif);
--rfh-max: var(--rfh-grid-max, 1232px);
container-type: inline-size;
position: relative;
z-index: 40;
overflow-x: clip;
background: var(--rfh-paper);
color: var(--rfh-ink);
font-family: var(--rfh-font);
font-optical-sizing: auto;
}
.rfh *,
.rfh *::before,
.rfh *::after {
box-sizing: border-box;
}
/* The grid's content edge: the gutter, or the centred max width plus the gutter. */
.rfh-bar,
.rfh-panel {
--rfh-pad: 20px;
--rfh-edge: max(var(--rfh-pad), calc((100cqw - var(--rfh-max)) / 2 + var(--rfh-pad)));
}
.rfh-bar {
display: flex;
align-items: stretch;
min-height: 64px;
border-bottom: 1px solid var(--rfh-line);
}
/* Nameplate: solid, from the screen's left edge; its text starts on the grid edge. */
.rfh-brand {
display: flex;
align-items: center;
flex: 1 1 auto;
min-width: 0;
padding: 8px 20px 8px var(--rfh-edge);
background: var(--rfh-plate-bg);
color: var(--rfh-plate-ink);
text-decoration: none;
}
.rfh[data-plate="paper"] .rfh-brand {
background: var(--rfh-hover);
color: var(--rfh-ink);
border-right: 1px solid var(--rfh-line);
}
.rfh-wordmark {
font-family: var(--rfh-font-display);
font-size: 19px;
font-weight: 720;
font-stretch: 90%;
letter-spacing: -0.018em;
line-height: 1.1;
text-wrap: balance;
}
.rfh[data-name="m"] .rfh-wordmark {
font-size: 17px;
}
.rfh[data-name="s"] .rfh-wordmark {
font-size: 16px;
}
.rfh-logo {
display: block;
height: 30px;
width: auto;
max-width: 200px;
object-fit: contain;
}
a.rfh-brand:hover .rfh-wordmark {
text-decoration: underline;
text-decoration-thickness: 1.5px;
text-underline-offset: 5px;
}
/* Page cells: equal ruled columns that share the band between the nameplate and the number. */
.rfh-nav {
display: none;
/* Basis 0: the cells share the band; the labels' own widths are the floor. */
flex: 1 1 0;
}
.rfh-nav ul {
display: grid;
flex: 1 1 auto;
grid-auto-flow: column;
grid-auto-columns: minmax(max-content, 1fr);
margin: 0;
padding: 0;
list-style: none;
}
.rfh-nav li {
display: flex;
border-left: 1px solid var(--rfh-line);
}
.rfh[data-plate="paper"] .rfh-nav li:first-child {
border-left: 0;
}
.rfh-link {
display: flex;
flex: 1 1 auto;
align-items: center;
padding: 0 20px;
color: var(--rfh-ink);
font-size: 15.5px;
font-weight: 560;
letter-spacing: -0.006em;
line-height: 1.2;
text-decoration: none;
white-space: nowrap;
transition: background-color 140ms ease;
}
.rfh-link:hover {
background: var(--rfh-hover);
}
.rfh-link[aria-current="page"] {
background: var(--rfh-hover);
box-shadow: inset 0 -4px 0 var(--rfh-accent);
font-weight: 650;
}
/* Paper between the nameplate and the right-hand cells; on phones the nameplate takes that room. */
.rfh-spacer {
display: none;
flex: 1 1 auto;
}
/* The number's cell: a 44px+ call cell (icon) wherever the written number does not fit. */
.rfh-call {
position: relative;
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: 12px;
min-width: 56px;
padding: 0 16px;
border-left: 1px solid var(--rfh-line);
color: var(--rfh-ink);
text-decoration: none;
transition: background-color 140ms ease;
}
.rfh-call:hover {
background: var(--rfh-hover);
}
.rfh-call-icon {
flex: none;
}
.rfh-call-text {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.rfh-call-label {
display: block;
font-size: 12.5px;
font-weight: 620;
line-height: 1.2;
color: var(--rfh-ink);
white-space: nowrap;
}
.rfh-call-number {
display: block;
font-family: var(--rfh-font-display);
font-size: 17px;
font-weight: 680;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
line-height: 1.2;
white-space: nowrap;
}
.rfh-call:hover .rfh-call-number {
text-decoration: underline;
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
}
/* The action: a filled block; when it closes the band it runs to the screen's right edge. */
.rfh-cta {
display: none;
flex: 0 0 auto;
align-items: center;
gap: 10px;
padding: 0 24px;
background: var(--rfh-accent);
color: var(--rfh-accent-ink);
font-size: 15.5px;
font-weight: 660;
letter-spacing: -0.006em;
text-decoration: none;
white-space: nowrap;
transition: background-color 140ms ease;
}
.rfh-cta svg,
.rfh-panel-cta svg {
flex: none;
transition: transform 160ms ease;
}
.rfh-cta:hover,
.rfh-panel-cta:hover {
background: var(--rfh-accent-deep);
}
.rfh-cta:hover svg,
.rfh-panel-cta:hover svg {
transform: translateX(3px);
}
/* Menu cell: closes the band whenever the pages are in the menu. */
.rfh-menu {
display: flex;
flex: 0 0 auto;
}
.rfh-toggle {
display: flex;
align-items: center;
gap: 10px;
min-width: 56px;
padding: 0 var(--rfh-edge) 0 16px;
border-left: 1px solid var(--rfh-line);
cursor: pointer;
list-style: none;
font-size: 15.5px;
font-weight: 600;
user-select: none;
transition: background-color 140ms ease;
}
.rfh-toggle::-webkit-details-marker {
display: none;
}
.rfh-toggle:hover,
.rfh-menu[open] .rfh-toggle {
background: var(--rfh-hover);
}
.rfh-toggle-text {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.rfh-toggle-icon {
position: relative;
display: inline-block;
width: 22px;
height: 22px;
}
.rfh-toggle-icon span {
position: absolute;
left: 1px;
right: 1px;
height: 2px;
background: currentColor;
transition: transform 200ms ease, top 200ms ease;
}
.rfh-toggle-icon span:first-child {
top: 6px;
}
.rfh-toggle-icon span:last-child {
top: 14px;
}
.rfh-menu[open] .rfh-toggle-icon span:first-child {
top: 10px;
transform: rotate(45deg);
}
.rfh-menu[open] .rfh-toggle-icon span:last-child {
top: 10px;
transform: rotate(-45deg);
}
/* The sheet under the band: the pages as ruled cells, then the action and the number. */
.rfh-panel {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: calc(100dvh - 64px);
overflow-y: auto;
background: var(--rfh-paper);
border-bottom: 1px solid var(--rfh-line);
box-shadow: 0 28px 40px -30px rgb(26 24 19 / 0.45);
}
.rfh-panel-inner {
padding: 0 var(--rfh-edge) 24px;
}
.rfh-panel-links {
display: grid;
grid-template-columns: 1fr;
margin: 0;
padding: 0;
list-style: none;
}
.rfh-panel-links li {
display: flex;
border-bottom: 1px solid var(--rfh-line);
}
.rfh-panel-links a {
display: flex;
flex: 1 1 auto;
align-items: center;
min-height: 60px;
padding: 10px 0;
color: var(--rfh-ink);
font-family: var(--rfh-font-display);
font-size: 22px;
font-weight: 600;
letter-spacing: -0.015em;
line-height: 1.2;
text-decoration: none;
}
.rfh-panel-links a:hover {
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 6px;
}
/* The current page: its rule turns into the 4px accent rule. */
.rfh-panel-links li:has(a[aria-current="page"]) {
border-bottom: 4px solid var(--rfh-accent);
}
.rfh-panel-links a[aria-current="page"] {
min-height: 57px;
font-weight: 700;
}
.rfh-panel-actions {
display: grid;
gap: 12px;
padding-top: 20px;
}
.rfh-panel-cta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 56px;
padding: 0 20px;
background: var(--rfh-accent);
color: var(--rfh-accent-ink);
font-size: 17px;
font-weight: 660;
text-decoration: none;
transition: background-color 140ms ease;
}
.rfh-panel-call {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 56px;
padding: 6px 20px;
border: 1px solid var(--rfh-line);
color: var(--rfh-ink);
text-decoration: none;
}
.rfh-panel-call:hover {
background: var(--rfh-hover);
}
/* Focus: an inset ring in the cell's own text colour, so it is never clipped by the band. */
.rfh a:focus-visible,
.rfh-toggle:focus-visible {
outline: 2px solid currentColor;
outline-offset: -6px;
}
/* Narrow phones: the menu cell is its icon; the name may take two lines on the plate. */
@container (min-width: 360px) {
.rfh-toggle-text {
position: static;
width: auto;
height: auto;
overflow: visible;
clip-path: none;
}
.rfh-toggle {
padding-left: 18px;
}
}
@container (min-width: 600px) {
.rfh-bar {
--rfh-pad: 24px;
}
.rfh-panel {
--rfh-pad: 24px;
}
.rfh-spacer {
display: block;
}
.rfh-brand {
flex: 0 0 auto;
padding-right: 32px;
}
.rfh[data-name="s"] .rfh-brand {
flex-shrink: 1;
}
.rfh-wordmark {
font-size: 21px;
}
.rfh[data-name="m"] .rfh-wordmark {
font-size: 19px;
}
.rfh[data-name="s"] .rfh-wordmark {
font-size: 17px;
}
.rfh[data-name="l"] .rfh-wordmark,
.rfh[data-name="m"] .rfh-wordmark {
white-space: nowrap;
}
.rfh-logo {
height: 36px;
}
.rfh-call {
min-width: 64px;
padding: 0 20px;
}
.rfh-cta {
display: flex;
}
.rfh-panel-links {
grid-template-columns: 1fr 1fr;
column-gap: 40px;
}
.rfh-panel-actions {
grid-template-columns: 1fr 1fr;
}
}
@container (min-width: 960px) {
.rfh-bar,
.rfh-panel {
--rfh-pad: var(--rfh-grid-gutter, 48px);
}
.rfh-bar {
min-height: 76px;
}
.rfh-panel {
max-height: calc(100dvh - 76px);
}
.rfh-panel-links {
grid-template-columns: repeat(3, 1fr);
}
.rfh-panel-actions {
grid-template-columns: repeat(3, 1fr);
}
}
/* No pages: no menu; the action shows at every width and closes the band. */
.rfh[data-links="0"] .rfh-cta {
display: flex;
}
.rfh[data-last="cta"][data-links="0"] .rfh-cta,
.rfh[data-last="call"][data-links="0"] .rfh-call {
padding-right: var(--rfh-edge);
}
/* The number written out, from the width the frontmatter computed. */
@container (min-width: 600px) {
.rfh[data-number="600"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
}
@container (min-width: 640px) {
.rfh[data-number="640"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
}
@container (min-width: 680px) {
.rfh[data-number="680"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
}
@container (min-width: 720px) {
.rfh[data-number="720"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
}
@container (min-width: 740px) {
.rfh[data-number="740"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
}
@container (min-width: 780px) {
.rfh[data-number="780"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
}
@container (min-width: 860px) {
.rfh[data-number="860"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
}
@container (min-width: 960px) {
.rfh[data-number="960"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
}
@container (min-width: 1080px) {
.rfh[data-number="1080"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
}
@container (min-width: 1180px) {
.rfh[data-number="1180"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
}
/* Pages inline, from the width the frontmatter computed: page cells replace the spacer and the menu cell. */
@container (min-width: 760px) {
.rfh[data-inline="760"] .rfh-nav { display: flex; }
.rfh[data-inline="760"] :is(.rfh-spacer, .rfh-menu) { display: none; }
.rfh[data-inline="760"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
.rfh[data-inline="760"][data-last="cta"] .rfh-cta,
.rfh[data-inline="760"][data-last="call"] .rfh-call { padding-right: var(--rfh-edge); }
}
@container (min-width: 860px) {
.rfh[data-inline="860"] .rfh-nav { display: flex; }
.rfh[data-inline="860"] :is(.rfh-spacer, .rfh-menu) { display: none; }
.rfh[data-inline="860"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
.rfh[data-inline="860"][data-last="cta"] .rfh-cta,
.rfh[data-inline="860"][data-last="call"] .rfh-call { padding-right: var(--rfh-edge); }
}
@container (min-width: 960px) {
.rfh[data-inline="960"] .rfh-nav { display: flex; }
.rfh[data-inline="960"] :is(.rfh-spacer, .rfh-menu) { display: none; }
.rfh[data-inline="960"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
.rfh[data-inline="960"][data-last="cta"] .rfh-cta,
.rfh[data-inline="960"][data-last="call"] .rfh-call { padding-right: var(--rfh-edge); }
}
@container (min-width: 1080px) {
.rfh[data-inline="1080"] .rfh-nav { display: flex; }
.rfh[data-inline="1080"] :is(.rfh-spacer, .rfh-menu) { display: none; }
.rfh[data-inline="1080"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
.rfh[data-inline="1080"][data-last="cta"] .rfh-cta,
.rfh[data-inline="1080"][data-last="call"] .rfh-call { padding-right: var(--rfh-edge); }
}
@container (min-width: 1180px) {
.rfh[data-inline="1180"] .rfh-nav { display: flex; }
.rfh[data-inline="1180"] :is(.rfh-spacer, .rfh-menu) { display: none; }
.rfh[data-inline="1180"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
.rfh[data-inline="1180"][data-last="cta"] .rfh-cta,
.rfh[data-inline="1180"][data-last="call"] .rfh-call { padding-right: var(--rfh-edge); }
}
@container (min-width: 1280px) {
.rfh[data-inline="1280"] .rfh-nav { display: flex; }
.rfh[data-inline="1280"] :is(.rfh-spacer, .rfh-menu) { display: none; }
.rfh[data-inline="1280"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
.rfh[data-inline="1280"][data-last="cta"] .rfh-cta,
.rfh[data-inline="1280"][data-last="call"] .rfh-call { padding-right: var(--rfh-edge); }
}
@container (min-width: 1400px) {
.rfh[data-inline="1400"] .rfh-nav { display: flex; }
.rfh[data-inline="1400"] :is(.rfh-spacer, .rfh-menu) { display: none; }
.rfh[data-inline="1400"] .rfh-call-text { position: static; width: auto; height: auto; overflow: visible; clip-path: none; }
.rfh[data-inline="1400"][data-last="cta"] .rfh-cta,
.rfh[data-inline="1400"][data-last="call"] .rfh-call { padding-right: var(--rfh-edge); }
}
/*
* Measured corrections (the script, only when the estimate was short in the loaded face):
* "menu" keeps the pages in the menu; "compact" also folds the number to its call cell.
*/
.rfh[data-fit] .rfh-nav {
display: none;
}
@container (min-width: 600px) {
.rfh[data-fit] .rfh-spacer {
display: block;
}
}
.rfh[data-fit] .rfh-menu {
display: flex;
}
.rfh[data-fit][data-links]:not([data-links="0"]) .rfh-cta {
padding-right: 24px;
}
.rfh[data-fit][data-links]:not([data-links="0"]) .rfh-call {
padding-right: 20px;
}
.rfh[data-fit="compact"][data-number] .rfh-call-text {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
@media (prefers-reduced-motion: reduce) {
.rfh-link,
.rfh-call,
.rfh-cta,
.rfh-cta svg,
.rfh-panel-cta svg,
.rfh-toggle,
.rfh-toggle-icon span,
.rfh-panel-links a {
transition: none;
}
.rfh-cta:hover svg,
.rfh-panel-cta:hover svg {
transform: none;
}
}
@media (forced-colors: active) {
.rfh-brand,
.rfh-cta,
.rfh-panel-cta {
border: 1px solid CanvasText;
}
.rfh-link[aria-current="page"],
.rfh-panel-links a[aria-current="page"] {
text-decoration: underline;
text-decoration-thickness: 3px;
text-underline-offset: 6px;
}
}
</style>
<script>
/*
* One controller per header, safe to run again after a client-side navigation. It measures the
* real band in the loaded face and steps down if it does not fit, and adds Escape / outside click /
* link choice to the native disclosure.
*/
function initRuledFasciaHeaders() {
for (const root of document.querySelectorAll<HTMLElement>("[data-rfh-root]:not([data-rfh-ready])")) {
root.setAttribute("data-rfh-ready", "");
const bar = root.querySelector<HTMLElement>(".rfh-bar");
const menu = root.querySelector<HTMLDetailsElement>("[data-rfh-menu]");
const summary = menu?.querySelector<HTMLElement>("summary") ?? null;
const ac = new AbortController();
const { signal } = ac;
const overflows = () => (bar ? bar.scrollWidth > bar.clientWidth + 1 : false);
let width = -1;
const fit = (force = false) => {
const now = root.clientWidth;
if (!force && now === width) return;
width = now;
delete root.dataset.fit;
if (overflows() && menu) root.dataset.fit = "menu";
if (overflows()) root.dataset.fit = "compact";
/* The pages went inline: an open sheet would only repeat them. */
if (menu?.open && getComputedStyle(menu).display === "none") menu.open = false;
};
fit(true);
const observer = new ResizeObserver(() => fit());
observer.observe(root);
document.fonts?.ready.then(() => fit(true));
if (menu && summary) {
root.addEventListener(
"keydown",
(event) => {
if (event.key === "Escape" && menu.open) {
event.preventDefault();
menu.open = false;
summary.focus();
}
},
{ signal },
);
menu.querySelector(".rfh-panel")?.addEventListener(
"click",
(event) => {
if ((event.target as HTMLElement).closest("a")) menu.open = false;
},
{ signal },
);
document.addEventListener(
"click",
(event) => {
if (menu.open && !root.contains(event.target as Node)) menu.open = false;
},
{ signal },
);
}
document.addEventListener(
"astro:before-swap",
() => {
observer.disconnect();
ac.abort();
},
{ once: true },
);
}
}
initRuledFasciaHeaders();
document.addEventListener("astro:page-load", initRuledFasciaHeaders);
</script>
===== END src/components/RuledFasciaHeader.astro =====
===== FILE (supporting): src/components/RuledFasciaHeader.md =====
# Ruled fascia header
A shopfront-fascia header: one full-bleed band of ruled compartments — a solid nameplate from the left edge, equal page cells, a number cell and a filled action block to the right edge. It measures its own content so the cells never wrap, keeps the number in the band at every width, and moves the pages into a native disclosure sheet below that width.
## Integration
- Put it first in <body> (after a skip link). It is in the normal flow and reserves its own height: nothing to offset.
- Pages come from `items`; the width at which they sit inline is computed from the name, the labels, the number and the action, so they never wrap — below it they are in the menu. A small script re-measures in the loaded face and only ever steps down (pages to the menu, then the number to its call cell).
- The number stays in the band at every width: written out where it fits, a 56–64px call cell elsewhere (its accessible name always includes the number).
- The menu is a native <details> disclosure and works without JavaScript; the script adds Escape, outside click and link choice. It re-initialises on astro:page-load and releases its listeners on astro:before-swap.
- Aligns to the site grid through --rfh-grid-max / --rfh-grid-gutter: the nameplate text starts and the action text ends on those content edges; the plate and the action colour bleed to the screen edges.
- With a logo drawn for a light ground the nameplate turns paper-toned automatically; pass plate="ink" for a light logo on the solid plate.
- Loads Bricolage Grotesque from Google Fonts by default. Pass fontStylesheet="" on a site that loads its own faces (via --rfh-family / --rfh-display).
## Provenance
Staged as candidate `ruled-fascia-header` (Step 28) and admitted through the candidate pipeline. Source type: original. References (direction only): none. Quality history: quality/candidates/records/ruled-fascia-header.json.
===== END src/components/RuledFasciaHeader.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.