Centralized nav and footer
One navbar and one footer live in partials/ and are stamped into 21 public pages by a sync script; editing the chrome inside a page does nothing.
Last updated 2026-08-06
Summary#
The public site's navbar and footer have exactly one source: markup in partials/, styling in css/site-chrome.css, behavior in js/mv-site-chrome.js. tests/site-chrome-sync.mjs stamps that markup into 21 public pages between marker comments.
The rule that follows from this is absolute and catches everyone once: editing the chrome inside a page does nothing. The next sync overwrites it, and until then the drift check fails. Edit the partial, run the sync, commit both sides. It is the same contract as tests/login-inline-sync.mjs.
The chrome is stamped rather than injected in the browser for one reason: this is an SEO product. Client-side injection would keep the navigation out of the initial HTML response.
Purpose#
Before this existed, the chrome was copy-pasted into roughly twenty-three pages. Each copy drifted. Each page also grew its own inline <style> for .topnav, .ftgrid, .wrap and .logo, plus somewhere between 27 and 57 bare footer { } rules. Adding one link to the footer was a twenty-page diff, and the twenty copies were never quite identical afterwards.
Two follow-on decisions come straight out of that history, and both look strange until you know why.
The chrome is namespaced .mv-* on purpose. A page's own inline <style> block comes after the shared <link> in source order, so a shared stylesheet using the old names would lose every specificity fight it entered. The shared chrome therefore uses class names nothing else in the codebase uses, and every root element carries a class, .mv-topnav and .mv-site-footer, so that a bare header { } or footer { } rule with specificity (0,0,1) cannot outrank it.
The orphaned per-page CSS is left in place deliberately. It no longer matches anything. Deleting it across twenty-three divergent files is a separate, individually verifiable change, and bundling it into this one would have made the diff unreviewable.
Architecture#
SOURCE STAMPED INTO
──────────────────────────────── ─────────────────────────────────────
partials/site-nav.html → <!--MV:NAV:START--> … <!--MV:NAV:END-->
partials/site-footer.html → <!--MV:FOOTER:START--> … <!--MV:FOOTER:END-->
partials/extras/*.html → <!--MV:SLOT name-->
<!--MV:IF lang--> … <!--MV:ENDIF-->
│
│ node tests/site-chrome-sync.mjs --write
▼
44 pages: index, pricing, solutions, about, contact, blog, blog-post,
legal + 5 legal/*, free-tools/index + 11 free-tools pages,
5 platform/*, 4 solutions/*, 9 reports/*
(node tests/site-chrome-sync.mjs --list prints the current set)
css/site-chrome.css the .mv-* styling, authored light-first
js/mv-site-chrome.js hamburger, collapsible submenu, language pillpartials/ is not shipped. It is a build-time input, like i18n/. What deploys is the stamped HTML. See Repository structure.
The sync script#
node tests/site-chrome-sync.mjs # drift check, exits 1 if stale
node tests/site-chrome-sync.mjs --write # rewrite the pages
node tests/site-chrome-sync.mjs --list # show the target set and exitExit codes are 0 for in sync or written, 1 for drift found, 2 for a configuration error. Run the no-argument form before committing any change to a public page.
Per page, --write does six things:
- Replaces the region between
MV:NAV:STARTandMV:NAV:END, and betweenMV:FOOTER:STARTandMV:FOOTER:END. On a first migration it falls back to matching<header class="…topnav…">…</header>and<footer>…</footer>, then writes the markers so subsequent runs are exact. - Fills each
<!--MV:SLOT name-->from that page'sextrasmap, indented to match its surroundings. - Keeps the
<!--MV:IF lang-->…<!--MV:ENDIF-->region only on pages whose entry setslang: true. - Adds
is-activeto the<a data-mv-nav="key">matching the page. - On
index.htmlonly, which is flaggedroot: true, rewriteshref="/#anchor"tohref="#anchor"so the homepage scrolls instead of re-navigating. - Ensures
/css/tokens.css,/css/site-chrome.css?v=Nand/js/mv-site-chrome.js?v=Nare linked. Eight free-tools pages had linked no stylesheet at all.
Cache-busting is manual#
The script no longer holds hand-bumped CHROME_CSS_V / CHROME_JS_V: the ?v= on every /css/* and /js/* reference is a hash of that file's content, stamped across all pages by node tests/asset-version-sync.mjs --write. Change the file, run the sync, commit both. Do not hand-edit a ?v=. Cloudflare serves /js/* and /css/* with public, max-age=14400, four hours, while the HTML is marked no-cache. So a returning visitor holds a four-hour-old copy of the chrome against fresh HTML, which is exactly how a shipped change can look as though it never deployed.
Bump those two constants when site-chrome.css or mv-site-chrome.js changes in a way visitors must pick up, then re-run --write. The script upgrades an existing ?v= in place rather than adding a second link.
Line endings#
Untouched regions stay byte-for-byte identical, and inserted text uses the file's dominant line ending. Several targets are genuinely mixed: legal/security.html is 1,682 CRLF lines and 778 bare-LF lines. Normalising the whole file, which is the obvious implementation, rewrote every line and buried a 100-line change in a 1,700-line diff. Every pattern in the script is therefore written \r?\n.
Components#
partials/site-nav.html#
Root <header class="mv-topnav">, containing .mv-nav-row with .mv-brand, <nav class="mv-nav-mid"> and .mv-nav-right.
| Label | href | data-mv-nav | |
|---|---|---|---|
| Home | / | home | |
| About | /about | about | |
| Platform | /#chapters | platform | dropdown |
| Pricing | /pricing | pricing | |
| Solutions | /solutions | solutions | dropdown |
| Free tools | /free-tools | free-tools | dropdown |
| Contact | /contact | contact | |
| Blog | /blog | blog |
Three entries are dropdowns. Each is a .mv-nav-dd wrapping a .mv-nav-dd-trigger (with the caret SVG) and a .mv-nav-dd-menu[role=menu] of role=menuitem links. Platform lists its five product pages behind a Platform overview item; Solutions lists Solutions overview, SEO Tools for Small Business, SEO Platform for Agencies, Semrush Alternative and Ahrefs Alternative; Free tools lists all twelve free tools.
A dropdown needs its overview item. Below 860px js/mv-site-chrome.js preventDefaults a trigger's click and toggles the submenu instead, so the trigger's own href cannot be reached on a phone. Platform and Solutions each carry an item pointing at the same URL as their trigger for this reason. Free tools does not, and reaches /free-tools through the footer instead.
A dropdown costs 15px of bar width (a 10px caret plus its 5px gap), and the horizontal bar has single-digit pixels of headroom at 1280. fitNav() in js/mv-site-chrome.js hands off to the hamburger the moment the links come within 16px of the logo or the CTA, so adding one without recovering the width elsewhere makes the whole navbar disappear at the most common laptop size. Measure before and after, at 1280, in more than English.
.mv-nav-right holds, in order: the nav-extra slot, Sign in, Start free →, the conditional language mount, and <button class="mv-nav-toggle" aria-label="Open menu" aria-expanded="false">. Two further mobile-only entries, Sign in and Start free →, live inside the collapsible menu.
Note: the MV:IF lang slot used to emit <blog-lang-switcher>, a custom element defined in js/mv-blog.js, which only dashboard.html loads. The dashboard does not use this navbar, so the element was inert on all 21 pages: an empty custom element taking up a flex gap. It now emits the standard [data-mv-lang] mount that Localization system builds into a real switcher.
partials/site-footer.html#
Root <footer class="mv-site-footer"> containing .mv-ftwrap, the footer-extra slot, a six-column .mv-ftgrid, and .mv-ftbottom.
The columns are Brand, Product, Free Tools, Account, Resources and Legal. The brand column carries the contact block: office Hollywood, FL 33020, phone +1 (866) 775-1331, email hello@metricvaultai.com. The bottom row reads © 2026 Metric Vault · All rights reserved. beside an All systems operational badge and a compact Privacy, Terms, Support, Status row.
The Free Tools column lists all eleven pages with slightly different labels from the navbar, notably Google AI Overview Checker and XML Sitemap Validator. An explicit comment in the partial records that there is deliberately no "All free tools" link in that column, restated so a re-sync cannot quietly put it back.
partials/extras/#
| File | Used by |
|---|---|
blog-subscribe.html | blog.html, through footer-extra |
blog-post-subscribe.html | blog-post.html, through footer-extra |
legal-language-switcher.html | Currently unused. The legal pages' old globe-and-code switcher; they now take the standard language widget |
The two subscribe blocks are byte-identical and both mount the newsletter embed under the headings Subscribe to our newsletter and Get new posts in your inbox.
css/site-chrome.css and the light-first rule#
css/tokens.css makes light the :root default and dark opt-in through [data-theme="dark"]. The old homepage chrome was authored the other way round, dark first with a [data-theme="light"] override, which worked only because index.html and pricing.html set data-theme="light" explicitly. The free-tools pages do not: their boot script sets the attribute only for dark.
So site-chrome.css is authored light-first with a [data-theme="dark"] override, and it renders correctly whether or not the light attribute is present. That is not a style preference, it is a correctness requirement, and it is the single most important thing to preserve when editing that file.
Concretely, .mv-topnav declares --mv-nav-bg: var(--panel) and --mv-nav-line: var(--border), and the dark override re-points them. The bar is position: sticky; top: 0; z-index: 50 with a backdrop blur, and .mv-nav-row is capped at 1340px.
js/mv-site-chrome.js#
Loaded defer on every public page. Three jobs, and it bails silently if the elements it needs are absent.
- Mobile hamburger. The open class is
.is-open. It used to be a bare.open, which collided with pages carrying an unrelated.openutility. Escape closes the menu and returns focus to the toggle. - Collapsible free-tools submenu below 860px. On desktop the trigger is a normal link and CSS
:hoverand:focus-withinhandle the dropdown; below 860px the trigger callspreventDefault()and toggles.is-openon the dropdown. The breakpoint is compared numerically rather than through a"(max-width: 860px)"string, because this file is scanned by the translation extractor, which treats bare string literals as UI copy and was pulling the media query into the baseline as something to translate into five languages. It must stay in step with the 860px breakpoint incss/site-chrome.css. - Language-pill relabelling. The i18n runtime builds the switcher labelled with the full language name; the navbar copies the sign-in page's pill, which shows the two-letter uppercase code instead. It is shorter, and it keeps the navigation from reflowing when the name is "Português". Because the runtime is a separate deferred script that may build the widget before or after this one runs, the relabelling uses a
MutationObserverplus anmvLangChangelistener. There are two mounts, one for desktop and one inside the collapsible menu, and both need relabelling.
Data flow#
The 21 target pages#
index.html, pricing.html, blog.html, blog-post.html, legal.html, the four legal/* pages, free-tools/index.html and the eleven free-tools pages. Every entry sets lang: true, and each names the data-mv-nav key that should be marked active.
Deliberately excluded, with reasons recorded in the script:
| Excluded | Why |
|---|---|
404.html | Deliberately chrome-less: a centered error page |
dashboard.html, admin.html, login.html, reports/*, ext-preview.html, chrome-extension/* | App surfaces, not the marketing site |
Adding a public page means adding it to PAGES in the sync script and running --write. It also means adding it to HTML_FILES in tests/i18n-extract.mjs and running node tests/i18n-wire.mjs, or the page ships untranslated.
The dark pin was removed#
This is the one place where CLAUDE.md is out of date, and re-introducing the behavior it describes would silently revert a shipped fix.
The eight AI and SEO free-tools pages once hardcoded a dark palette in their own inline :root with no [data-theme] blocks at all. They did not define --panel, so once they linked tokens.css the navbar picked up the light --panel while their own --text stayed near-white: white on white. The sync script pinned data-theme="dark" on them to fix that at the source.
A later change removed those inline dark palettes and made the pages properly theme-aware. Leaving the pin in place then made --write re-force dark mode on all eight and revert the fix, which is exactly what happened the first time the sync ran after that merge. No page in PAGES sets theme: today. The flag is still implemented, because pinning a genuinely single-theme page is a legitimate need, but do not set it on a page that can render in both.
All eight now carry the standard opt-in boot script, which sets the attribute only when the stored preference is dark. That variant leaves no attribute at all in light mode, which is precisely why the chrome must be authored light-first.
Failure modes#
| Failure | Symptom | Cause | Fix |
|---|---|---|---|
| An edit to the navbar vanishes | The change is gone after someone runs the sync | The chrome was edited inside a page | Edit partials/site-nav.html, run --write, commit both |
| CI or the pre-commit check fails on a page you did not touch | Exit 1 from the drift check | Someone edited a page's chrome, or a partial changed without a re-sync | node tests/site-chrome-sync.mjs --write |
| A chrome change does not appear for returning visitors | Old navbar, new page content | /css/* and /js/* carry a four-hour cache | Bump CHROME_CSS_V and CHROME_JS_V, re-run --write, and bump CACHE_NAME in sw.js |
| White text on a white navbar | Unreadable header on a free-tools page | A page defines part of the palette but not --panel, or the chrome was re-authored dark-first | Keep site-chrome.css light-first; do not depend on [data-theme="light"] being present |
| A page renders dark for everyone | The theme toggle appears to do nothing | A theme: pin was re-added to that page's entry | Remove it. The pages are theme-aware now |
| A shared nav rule is beaten by page CSS | Only one page looks wrong | That page's inline <style> matched a .mv-* name | Rename. The namespace exists so nothing else uses those names |
| A new page has no navbar | Chrome missing entirely | It is not in PAGES | Add it, run --write, and wire it for translation too |
| The language pill shows a full language name | Navigation reflows on Português | The relabelling never ran, or a third mount was added | Both mounts are relabelled by mv-site-chrome.js through an observer and the mvLangChange event |
| A media query string turns up in the translation baseline | Five languages want to translate (max-width: 860px) | A bare string literal was added to mv-site-chrome.js | Compare widths numerically, as the existing code does |
| A page diff is 1,700 lines for a 100-line change | Unreviewable commit | Line endings were normalised | The script preserves the dominant ending; do not run a formatter over these files |
See also
Was this article helpful?
Thanks — feedback noted for the docs team.