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 "Title block footer" from my Astro Component Library as this project's site header.
This is a header, not a page section. It replaces something that already exists, over routes that already exist, under CSS that already exists. Work through the steps in order and report as you go.
--- WHAT THIS HEADER IS ---
Component: Title block footer
Library page: https://astro.baysixmedia.com/components/title-block-footer/
Navigation mode: static — a flat list of links, with no panels to open
Position mode: normal
Desktop dropdowns: no
Separate mobile menu: no
Locks body scroll while open: no
Astro client navigation: supported — it re-initialises on astro:page-load and detaches its listeners before each swap
Required files: 1
Dependencies: none — no npm packages, no CSS framework, no UI framework
Assets: none
Prop interface, verbatim from the component:
interface Props {
businessName?: string;
logo?: { src: string; alt?: string; width?: number; height?: number } | null;
homeHref?: string;
description?: string;
/** The one action, in the action column under the number (or in the inverted cell when there is no number). */
cta?: TitleBlockFooterLink | null;
primaryLinks?: readonly TitleBlockFooterLink[];
serviceLinks?: readonly TitleBlockFooterLink[];
legalLinks?: readonly TitleBlockFooterLink[];
social?: readonly TitleBlockFooterLink[];
phone?: { number: string } | null;
email?: string;
address?: string | readonly string[];
addressHref?: string;
hours?: string | readonly string[];
labels?: { pages?: string; services?: string; social?: string; call?: string; email?: string; visit?: string; hours?: string };
copyright?: 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:
- Goes at the end of the layout, after <slot />, outside <main>. It is a <footer>: the contentinfo landmark at the top level.
- Each link group is a <nav> labelled by its title; the facts are a <dl>. Every link, group and fact renders only when supplied: an address, hours or number never without one.
- The number is the inverted field and the whole field is the tel: link. The one action (cta) sits under it; with no number the action takes the inverted field.
- Loads Newsreader and Inter Tight from Google Fonts by default. Pass fontStylesheet="" on a site that loads its own faces.
--- EXPECTED BEHAVIOUR ---
Desktop: every link is reachable in one tab pass, with no panels to open.
Mobile: the same markup reflows; there is no separate menu to open.
Accessibility: a single landmark <nav> with an accessible name, aria-current on the link for the current route, visible focus on every control, 44x44px minimum targets, full prefers-reduced-motion support, and a working no-JavaScript fallback. None of this may be removed while adapting the design.
--- STEPS ---
1. Inspect this project before editing anything. Find its Astro version, whether it uses <ClientRouter /> or view transitions, its global stylesheet, and its existing header. Report what you found before changing a file.
2. Find the real shared layout — the one every page actually renders through — and the current header inside it. Do not assume src/layouts/Layout.astro.
3. Inventory every current navigation route: read the existing header, src/pages/, and any route or nav-data file. List every label and href before touching anything.
4. Map those real routes into this component's navigation structure. Use the destination's own routes and labels. Do not ship the demo tree, and do not invent routes to fill out a panel that looks empty.
5. Preserve the destination's branding and approved copy: its brand name, wordmark, logo mark, button labels and tone. Adapt the component's colour and type props to the destination's tokens.
6. Identify global CSS in this project that targets header, nav, a, button, ul or body, and anything that sets overflow or transform on an ancestor of the header. Those are what break an installed header. Report the collisions and how you resolved each one.
7. Build the replacement alongside the existing header first. Remove the old one only once the new one renders and works on every route.
8. Remove the old header's JavaScript and CSS once it is gone — menu toggles, scroll listeners, body-scroll locks, media-query handlers and their stylesheets. Leaving them causes double body locks and phantom listeners.
9. Make sure exactly one header renders. Check for a second one in a nested layout, in an individual page, or left behind in the old markup.
10. Wire up positioning. It sits in the normal flow and scrolls away with the page. Nothing needs reserving for it.
11. No layout height needs reserving for this one — say so explicitly rather than adding padding that would leave a gap.
12. Preserve the skip link. If the project has one it must still be the first focusable element and must still land on the main content; if a normal header would cover the target, add scroll-margin-top to it. If the project has no skip link, add one.
13. Leave analytics, meta tags, SEO, structured data and unrelated scripts in the layout exactly as they are. Removing a header is not a reason to touch anything else in <head>.
14. Make it work on a normal page load and, if this project uses client-side routing, across client navigation too: state reset between pages, no listeners accumulating, no body scroll lock surviving a navigation.
15. Visit every real route in this project and confirm the header renders, the correct link is marked current, and every href resolves. No 404s.
16. Test the whole site at 1440, 1280, 1024, 834, 390 and 320px: no horizontal overflow, no clipped panels, keyboard operation throughout, and the header never covering focused content.
--- FILES ---
Write each block below to the path in its header. Only the file marked (required) is needed to render; the supporting files are documentation and worked examples.
===== FILE (required): src/components/TitleBlockFooter.astro =====
---
/**
* TitleBlockFooter (candidate — original, role `footer`)
*
* A light footer drawn as one ruled sheet, the way a drawing's title block is: a heavy frame, hairline
* rules, and every piece of information in its own field. Nothing floats in columns; every field is a
* cell with its label at the top and its value under it, so values across a row share a first line.
*
* From tablet up the whole sheet sits on one spine of six equal tracks, and every row divides on it,
* so no rule stands on its own: the action column's edge, the fact fields and the legal split all
* fall on the same lines, and index links start where the fact values start.
*
* ┌───────────────────────────────┬───────────────┐
* │ Business name (display face) │ Call │ ← the call cell is the one inverted field:
* │ one or two sentences │ (650) 555-… │ the number is the footer's main path
* │ ├───────────────┤
* │ │ Book … [→] │ ← the action sits directly under it
* ├───────────────┬───────────────┼───────────────┤
* │ Email │ Visit │ Hours │ ← facts take equal fields on the spine
* ├───────┬───────┴───────┬───────┴───────┬───────┤
* │ Pages │ link │ link │ link │ link │ link │ ← link groups are index rows on the same
* │ Serv. │ link │ link │ link │ │ │ tracks, not columns
* ├───────┴───────────────────────┬───────┴───────┤
* │ © year name │ Privacy Terms │
* └───────────────────────────────┴───────────────┘
* (the inner track lines of the index rows are drawn here for reading only; the sheet draws none)
*
* On a tablet the call and action share a row (four tracks and two), facts keep their fields, and
* each index row sets its title over links on three tracks. On a phone the sheet is one column:
* name, the call cell, the action, facts as label/value rows, link groups two-up, legal.
*
* Truth rules. Every fact renders only when supplied: no number, address, hours or social profile is
* ever drawn without one. tel:/mailto: from supplied values; an address links only to a supplied https
* maps URL. Empty cells and empty groups are never drawn, and the sheet re-divides itself for what is
* left. With no number, the action takes the inverted cell; with neither, the name spans the sheet.
*
* Reads, never declares: --tbf-bg, --tbf-text, --tbf-text-muted, --tbf-brand, --tbf-brand-text,
* --tbf-border, --tbf-family, --tbf-display, --tbf-grid-max, --tbf-grid-gutter.
*/
export interface TitleBlockFooterLink {
label: string;
href: string;
}
interface Props {
businessName?: string;
logo?: { src: string; alt?: string; width?: number; height?: number } | null;
homeHref?: string;
description?: string;
/** The one action, in the action column under the number (or in the inverted cell when there is no number). */
cta?: TitleBlockFooterLink | null;
primaryLinks?: readonly TitleBlockFooterLink[];
serviceLinks?: readonly TitleBlockFooterLink[];
legalLinks?: readonly TitleBlockFooterLink[];
social?: readonly TitleBlockFooterLink[];
phone?: { number: string } | null;
email?: string;
address?: string | readonly string[];
addressHref?: string;
hours?: string | readonly string[];
labels?: { pages?: string; services?: string; social?: string; call?: string; email?: string; visit?: string; hours?: string };
copyright?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
const {
businessName = "",
logo = null,
homeHref = "/",
description = "",
cta = null,
primaryLinks = [],
serviceLinks = [],
legalLinks = [],
social = [],
phone = null,
email = "",
address,
addressHref,
hours,
labels = {},
copyright,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,500&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 clean = (list: readonly TitleBlockFooterLink[]) =>
list.map((l) => ({ label: (l?.label ?? "").trim(), href: safeHref(l?.href) })).filter((l): l is { label: string; href: string } => Boolean(l.label && l.href));
const name = businessName.trim();
const blurb = description.trim();
const home = safeHref(homeHref);
const hasLogo = Boolean(logo?.src);
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(), href: `tel:${digits}` } : null;
const mail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? email.trim() : "";
const lines = (v: string | readonly string[] | undefined) => (Array.isArray(v) ? v : v ? [v] : []).map((x) => String(x).trim()).filter(Boolean);
const addressLines = lines(address);
const hourLines = lines(hours);
const mapHref = addressHref && /^https:\/\//i.test(addressHref.trim()) ? addressHref.trim() : null;
const pages = clean(primaryLinks);
const services = clean(serviceLinks);
const socials = clean(social).filter((l) => /^https?:\/\//i.test(l.href));
const legal = clean(legalLinks);
const groups = [
...(pages.length ? [{ title: labels.pages ?? "Pages", links: pages, external: false }] : []),
...(services.length ? [{ title: labels.services ?? "Services", links: services, external: false }] : []),
...(socials.length ? [{ title: labels.social ?? "Follow", links: socials, external: true }] : []),
];
type Fact = { key: string; values: string[]; href?: string; kind: string };
const facts: Fact[] = [
...(mail ? [{ key: labels.email ?? "Email", values: [mail], href: `mailto:${mail}`, kind: "email" }] : []),
...(addressLines.length ? [{ key: labels.visit ?? "Visit", values: addressLines, href: mapHref ?? undefined, kind: "visit" }] : []),
...(hourLines.length ? [{ key: labels.hours ?? "Hours", values: hourLines, kind: "hours" }] : []),
];
/* The inverted cell carries the number; with no number it carries the action instead. */
const lead = call ? "call" : action ? "action" : null;
const second = call && action ? action : null;
const hasIdentity = Boolean(name || hasLogo || blurb);
/* At desktop the action column (call over action) sits beside the identity field. */
const split = Boolean(lead && hasIdentity);
const year = new Date().getFullYear();
const rights = copyright === undefined ? (name ? `© ${year} ${name}` : "") : copyright.trim();
const hasLegal = Boolean(rights || legal.length);
const brandLabel = name || logo?.alt || "";
---
{(hasIdentity || lead || facts.length > 0 || groups.length > 0) && (
<footer class:list={["tbf", className]} id={id}>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="tbf-inner">
<div class:list={["tbf-sheet", { "tbf-sheet--split": split }]}>
{hasIdentity && (
<div class="tbf-cell tbf-identity">
{(name || hasLogo) && (home ? (
<a class="tbf-brand" href={home}>
{hasLogo ? <img class="tbf-logo" src={logo!.src} alt={logo!.alt || name} width={logo!.width} height={logo!.height} /> : <span class="tbf-wordmark">{name}</span>}
</a>
) : (
<span class="tbf-brand">
{hasLogo ? <img class="tbf-logo" src={logo!.src} alt={logo!.alt || name} width={logo!.width} height={logo!.height} /> : <span class="tbf-wordmark">{name}</span>}
</span>
))}
{blurb && <p class="tbf-description">{blurb}</p>}
</div>
)}
{lead && (
<div class:list={["tbf-action", { "tbf-action--pair": Boolean(second) }]}>
{lead === "call" && call ? (
<a class="tbf-cell tbf-lead tbf-lead--call" href={call.href}>
<span class="tbf-lead-label">{labels.call ?? "Call"}{brandLabel ? <span class="tbf-sr"> {brandLabel}</span> : null}</span>
<span class="tbf-number">{call.number}</span>
</a>
) : action ? (
<a class="tbf-cell tbf-lead tbf-lead--action" href={action.href}>
<span class="tbf-lead-text">{action.label}</span>
<svg class="tbf-arrow" aria-hidden="true" viewBox="0 0 24 24" width="24" height="24"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8" /></svg>
</a>
) : null}
{second && (
<a class="tbf-cell tbf-go" href={second.href}>
<span class="tbf-go-text">{second.label}</span>
<span class="tbf-go-mark" aria-hidden="true">
<svg class="tbf-arrow" viewBox="0 0 24 24" width="22" height="22"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8" /></svg>
</span>
</a>
)}
</div>
)}
{facts.length > 0 && (
<dl class="tbf-facts" data-count={String(facts.length)}>
{facts.map((f) => (
<div class:list={["tbf-cell", "tbf-fact", `tbf-fact--${f.kind}`]}>
<dt class="tbf-label">{f.key}</dt>
<dd>
{f.href ? (
<a href={f.href}>{f.values.map((v) => <span>{v}</span>)}</a>
) : (
f.values.map((v) => <span>{v}</span>)
)}
</dd>
</div>
))}
</dl>
)}
{groups.length > 0 && (
<div class="tbf-index">
{groups.map((g) => (
<nav class="tbf-row" aria-label={g.title}>
<p class="tbf-cell tbf-row-title tbf-label" aria-hidden="true">{g.title}</p>
<ul class="tbf-cell tbf-row-links" role="list" data-many={g.links.length > 4 ? "" : undefined}>
{g.links.map((l) => (
<li>
<a href={l.href} rel={g.external ? "me noopener" : undefined}>{l.label}</a>
</li>
))}
</ul>
</nav>
))}
</div>
)}
{hasLegal && (
<div class:list={["tbf-legal", { "tbf-legal--split": Boolean(rights) && legal.length > 0 }]}>
{rights && <p class="tbf-cell tbf-rights">{rights}</p>}
{legal.length > 0 && (
<ul class="tbf-cell tbf-policies" role="list">
{legal.map((l) => <li><a href={l.href}>{l.label}</a></li>)}
</ul>
)}
</div>
)}
</div>
</div>
</footer>
)}
<style>
.tbf {
--tbf-paper: var(--tbf-bg, #f2f0e9);
--tbf-ink: var(--tbf-text, #15171a);
--tbf-soft: var(--tbf-text-muted, #3b3f45);
--tbf-accent: var(--tbf-brand, #1f3d2e);
--tbf-accent-ink: var(--tbf-brand-text, #f4f2ea);
--tbf-rule: var(--tbf-border, #8a8474);
--tbf-font: var(--tbf-family, "Inter Tight", ui-sans-serif, system-ui, sans-serif);
--tbf-font-display: var(--tbf-display, "Newsreader", ui-serif, Georgia, serif);
--tbf-pad-x: 20px;
--tbf-pad-y: 20px;
container-type: inline-size;
background: var(--tbf-paper);
color: var(--tbf-ink);
font-family: var(--tbf-font);
-webkit-font-smoothing: antialiased;
}
.tbf-inner {
box-sizing: border-box;
max-width: var(--tbf-grid-max, 1232px);
margin: 0 auto;
padding: 40px 16px 32px;
}
.tbf a {
color: inherit;
text-decoration: none;
}
.tbf a:focus-visible {
outline: 2px solid var(--tbf-ink);
outline-offset: 2px;
border-radius: 1px;
}
.tbf-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* The sheet: a heavy frame; the 1px gaps between cells show the rule colour behind them. */
.tbf-sheet {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1px;
border: 2px solid var(--tbf-ink);
background: var(--tbf-rule);
}
.tbf-cell {
box-sizing: border-box;
margin: 0;
min-width: 0;
background: var(--tbf-paper);
padding: var(--tbf-pad-y) var(--tbf-pad-x);
}
.tbf-label {
margin: 0;
font-size: 13.5px;
font-weight: 600;
line-height: 20px;
letter-spacing: 0.005em;
color: var(--tbf-soft);
}
/* Identity */
.tbf-identity {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 18px;
padding-top: 24px;
padding-bottom: 26px;
}
.tbf-brand {
display: inline-flex;
align-items: center;
align-self: flex-start;
min-height: 44px;
max-width: 100%;
}
.tbf-wordmark {
font-family: var(--tbf-font-display);
font-size: 34px;
font-weight: 400;
line-height: 1.04;
letter-spacing: -0.022em;
overflow-wrap: anywhere;
text-wrap: balance;
}
.tbf-logo {
display: block;
height: 44px;
width: auto;
max-width: 260px;
object-fit: contain;
}
.tbf-description {
margin: 0;
max-width: 44ch;
font-size: 16.5px;
line-height: 1.55;
color: var(--tbf-soft);
text-wrap: pretty;
}
/* Action column: the inverted call cell, then the one action. */
.tbf-action {
display: grid;
gap: 1px;
min-width: 0;
}
.tbf .tbf-lead {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 22px;
min-height: 128px;
background: var(--tbf-accent);
color: var(--tbf-accent-ink);
}
.tbf-lead-label {
font-size: 13.5px;
font-weight: 600;
line-height: 20px;
color: color-mix(in srgb, var(--tbf-accent-ink) 84%, var(--tbf-accent));
}
.tbf-number {
font-family: var(--tbf-font-display);
font-size: 34px;
font-weight: 500;
line-height: 1.05;
letter-spacing: -0.012em;
font-variant-numeric: lining-nums tabular-nums;
overflow-wrap: anywhere;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.18em;
text-decoration-color: transparent;
transition: text-decoration-color 180ms ease;
}
.tbf .tbf-lead:hover .tbf-number {
text-decoration-color: currentColor;
}
/* With no number the action is the inverted field; on its own row it needs less height. */
.tbf .tbf-lead--action {
flex-direction: row;
align-items: flex-end;
min-height: 96px;
font-family: var(--tbf-font-display);
font-size: 26px;
font-weight: 500;
line-height: 1.15;
letter-spacing: -0.014em;
}
.tbf-lead-text {
overflow-wrap: anywhere;
}
.tbf-lead--action .tbf-arrow {
margin-bottom: 4px;
}
.tbf .tbf-lead:focus-visible {
outline: 2px solid var(--tbf-accent-ink);
outline-offset: -6px;
}
/* The second action: the display face and a solid brand mark, so it reads as the next path, not a link. */
.tbf .tbf-go {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 80px;
padding-block: 16px;
font-family: var(--tbf-font-display);
font-size: 23px;
font-weight: 500;
line-height: 1.15;
letter-spacing: -0.012em;
transition: background-color 160ms ease;
}
.tbf-go-text {
min-width: 0;
overflow-wrap: anywhere;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
text-decoration-color: transparent;
transition: text-decoration-color 180ms ease;
}
.tbf-go-mark {
flex: none;
display: grid;
place-items: center;
width: 48px;
height: 48px;
background: var(--tbf-accent);
color: var(--tbf-accent-ink);
}
.tbf .tbf-go:hover {
background: color-mix(in srgb, var(--tbf-ink) 5%, var(--tbf-paper));
}
.tbf .tbf-go:hover .tbf-go-text {
text-decoration-color: currentColor;
}
.tbf .tbf-go:focus-visible {
outline-offset: -5px;
}
.tbf-arrow {
flex: none;
transition: transform 180ms ease;
}
.tbf .tbf-go:hover .tbf-arrow,
.tbf .tbf-lead--action:hover .tbf-arrow {
transform: translateX(3px);
}
/* Facts: label/value rows on a phone, equal fields on the spine from tablet up. */
.tbf-facts {
display: grid;
gap: 1px;
margin: 0;
min-width: 0;
}
.tbf-fact {
display: grid;
grid-template-columns: 5.5rem minmax(0, 1fr);
column-gap: 12px;
align-items: start;
padding-block: 14px;
}
.tbf-fact dt {
padding-top: 2px;
}
.tbf-fact dd {
margin: 0;
font-size: 16.5px;
font-weight: 500;
line-height: 1.45;
overflow-wrap: anywhere;
}
.tbf-fact dd span {
display: block;
text-wrap: pretty;
}
.tbf-fact dd a {
display: inline-flex;
flex-direction: column;
justify-content: center;
min-height: 44px;
margin-block: -10px;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 4px;
text-decoration-color: var(--tbf-rule);
transition: text-decoration-color 160ms ease;
}
.tbf-fact dd a:hover {
text-decoration-color: currentColor;
}
/* Link groups as index rows. */
.tbf-index {
display: grid;
gap: 1px;
min-width: 0;
}
.tbf-row {
display: grid;
background: var(--tbf-paper);
min-width: 0;
}
.tbf-row-title {
padding-bottom: 2px;
}
.tbf-row-links {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 16px;
list-style: none;
padding-top: 2px;
padding-bottom: 10px;
}
.tbf-row-links li {
min-width: 0;
}
.tbf-row-links a,
.tbf-policies a {
display: inline-flex;
align-items: center;
min-height: 44px;
padding-block: 4px;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 5px;
text-decoration-color: transparent;
transition: text-decoration-color 180ms ease;
}
.tbf-row-links a {
box-sizing: border-box;
align-items: flex-start;
padding-block: 11px;
font-size: 16px;
font-weight: 500;
line-height: 1.3;
overflow-wrap: anywhere;
}
.tbf-row-links a:hover,
.tbf-policies a:hover {
text-decoration-color: currentColor;
}
/* Legal */
.tbf-legal {
display: grid;
gap: 1px;
min-width: 0;
}
.tbf-legal > .tbf-cell {
display: flex;
flex-wrap: wrap;
align-items: center;
align-content: center;
min-height: 60px;
padding-block: 8px;
}
.tbf-rights {
font-size: 14px;
line-height: 1.5;
color: var(--tbf-soft);
}
.tbf-policies li {
display: flex;
}
.tbf-policies {
gap: 0 24px;
list-style: none;
font-size: 14px;
font-weight: 500;
color: var(--tbf-ink);
}
/* Narrow phones: a fact's label sits over its value so an address or email keeps whole words. */
@container (max-width: 379px) {
.tbf-fact {
grid-template-columns: minmax(0, 1fr);
row-gap: 6px;
}
.tbf-fact dd a {
margin-block: -8px;
}
}
/*
* Tablet up: every row divides on one spine of six equal tracks. Each row is full width and uses the
* same track template and the same 1px gap, so its divisions fall exactly on the sheet's lines.
*/
@container (min-width: 640px) {
.tbf-inner {
--tbf-pad-x: 24px;
--tbf-pad-y: 22px;
padding: 56px var(--tbf-grid-gutter, 32px) 44px;
}
.tbf-sheet,
.tbf-action--pair,
.tbf-facts,
.tbf-legal--split {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.tbf-sheet > * {
grid-column: 1 / -1;
}
.tbf-wordmark {
font-size: 44px;
}
.tbf-number {
font-size: 38px;
}
/* Call on four tracks, the action on two: its edge is the same line as the Visit/Hours rule. */
.tbf-action--pair > .tbf-lead {
grid-column: 1 / 5;
}
.tbf-action--pair > .tbf-go {
grid-column: 5 / 7;
}
.tbf-fact {
grid-column: span 2;
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 10px;
padding-block: var(--tbf-pad-y);
}
.tbf-facts[data-count="2"] .tbf-fact {
grid-column: span 3;
}
.tbf-facts[data-count="1"] .tbf-fact {
grid-column: 1 / -1;
}
.tbf-fact dt {
padding-top: 0;
}
/* Index rows: the title over links on three tracks, each two spine tracks wide, so every link starts where a fact value starts. */
.tbf-row-links {
grid-template-columns: repeat(3, minmax(0, 1fr));
column-gap: 1px;
padding-inline: 0;
}
.tbf-row-links a {
padding-inline: var(--tbf-pad-x) 8px;
}
.tbf-legal--split > .tbf-rights {
grid-column: 1 / 5;
}
.tbf-legal--split > .tbf-policies {
grid-column: 5 / 7;
}
}
/* Desktop: the action column (call over action) takes the last two tracks beside the identity. */
@container (min-width: 1080px) {
.tbf-inner {
--tbf-pad-x: 28px;
--tbf-pad-y: 26px;
padding: 88px var(--tbf-grid-gutter, 48px) 64px;
}
.tbf-wordmark {
font-size: clamp(48px, 4.4cqi, 64px);
}
.tbf-description {
font-size: 17px;
}
.tbf-identity {
padding-top: 30px;
padding-bottom: 30px;
}
.tbf-sheet--split > .tbf-identity {
grid-column: 1 / 5;
grid-row: 1;
}
.tbf-sheet--split > .tbf-action {
grid-column: 5 / 7;
grid-row: 1;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) auto;
}
.tbf-sheet--split > .tbf-action > * {
grid-column: 1;
}
.tbf .tbf-lead {
min-height: 132px;
}
.tbf .tbf-sheet--split .tbf-go {
min-height: 92px;
font-size: 25px;
}
.tbf .tbf-lead--action {
font-size: 30px;
}
.tbf-facts[data-count="1"] .tbf-fact {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 1px;
padding: 0;
background: var(--tbf-rule);
}
.tbf-facts[data-count="1"] .tbf-fact > * {
display: flex;
align-items: center;
align-self: stretch;
background: var(--tbf-paper);
padding: 18px var(--tbf-pad-x);
}
.tbf-facts[data-count="1"] .tbf-fact dd {
grid-column: 2 / -1;
flex-direction: column;
justify-content: center;
align-items: flex-start;
padding-block: 12px;
}
.tbf-facts[data-count="1"] .tbf-fact dd a {
margin-block: 0;
}
.tbf-number {
font-size: clamp(30px, 3cqi, 44px);
white-space: nowrap;
}
/* Index rows: the title in the first track, links on the other five, one link per track. */
.tbf-row {
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 1px;
background: var(--tbf-rule);
}
.tbf-row-title {
grid-column: 1;
padding-block: 18px;
}
.tbf-row-links {
grid-column: 2 / -1;
grid-template-columns: repeat(5, minmax(0, 1fr));
padding-block: 6px;
}
}
@media (prefers-reduced-motion: reduce) {
.tbf-number,
.tbf-go,
.tbf-go-text,
.tbf-arrow,
.tbf-fact dd a,
.tbf-row-links a,
.tbf-policies a {
transition: none;
}
.tbf .tbf-go:hover .tbf-arrow,
.tbf .tbf-lead--action:hover .tbf-arrow {
transform: none;
}
}
@media (forced-colors: active) {
.tbf-sheet {
border-color: CanvasText;
}
.tbf-cell {
outline: 1px solid CanvasText;
}
.tbf-go-mark {
border: 1px solid CanvasText;
}
}
</style>
===== END src/components/TitleBlockFooter.astro =====
===== FILE (supporting): src/components/TitleBlockFooter.md =====
# Title block footer
A light footer drawn as one ruled sheet: a heavy frame, hairline rules and a field for every fact. The name in a display serif, the number in the one inverted field (the main call path) with the action under it, Email, Visit and Hours in equal fields, link groups as full-width index rows with one link per track, and every row divided on one six-track spine. Every fact only when supplied; empty fields and rows are never drawn.
## Integration
- Goes at the end of the layout, after <slot />, outside <main>. It is a <footer>: the contentinfo landmark at the top level.
- Each link group is a <nav> labelled by its title; the facts are a <dl>. Every link, group and fact renders only when supplied: an address, hours or number never without one.
- The number is the inverted field and the whole field is the tel: link. The one action (cta) sits under it; with no number the action takes the inverted field.
- Loads Newsreader and Inter Tight from Google Fonts by default. Pass fontStylesheet="" on a site that loads its own faces.
## Provenance
Staged as candidate `title-block-footer` (Step 28) and admitted through the candidate pipeline. Source type: original. References (direction only): https://forteraglobal.com/, https://stripe.com/, https://attio.com/. Quality history: quality/candidates/records/title-block-footer.json.
===== END src/components/TitleBlockFooter.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: not applicable — this component ships no script.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.
--- FINISH ---
Run this project's own check and build commands. Report: the layout file you changed, the routes you mapped and what you mapped them to, the CSS collisions you found, what you deleted from the old header, and anything you could not verify. Do not commit and do not deploy.