Skip to content
Metric VaultHelp Center
Open app

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#

text
  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 pill

partials/ is not shipped. It is a build-time input, like i18n/. What deploys is the stamped HTML. See Repository structure.

The sync script#

text
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 exit

Exit 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:

  1. Replaces the region between MV:NAV:START and MV:NAV:END, and between MV:FOOTER:START and MV: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.
  2. Fills each <!--MV:SLOT name--> from that page's extras map, indented to match its surroundings.
  3. Keeps the <!--MV:IF lang-->…<!--MV:ENDIF--> region only on pages whose entry sets lang: true.
  4. Adds is-active to the <a data-mv-nav="key"> matching the page.
  5. On index.html only, which is flagged root: true, rewrites href="/#anchor" to href="#anchor" so the homepage scrolls instead of re-navigating.
  6. Ensures /css/tokens.css, /css/site-chrome.css?v=N and /js/mv-site-chrome.js?v=N are 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.

Labelhrefdata-mv-nav
Home/home
About/aboutabout
Platform/#chaptersplatformdropdown
Pricing/pricingpricing
Solutions/solutionssolutionsdropdown
Free tools/free-toolsfree-toolsdropdown
Contact/contactcontact
Blog/blogblog

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

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/#

FileUsed by
blog-subscribe.htmlblog.html, through footer-extra
blog-post-subscribe.htmlblog-post.html, through footer-extra
legal-language-switcher.htmlCurrently 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.

  1. Mobile hamburger. The open class is .is-open. It used to be a bare .open, which collided with pages carrying an unrelated .open utility. Escape closes the menu and returns focus to the toggle.
  2. Collapsible free-tools submenu below 860px. On desktop the trigger is a normal link and CSS :hover and :focus-within handle the dropdown; below 860px the trigger calls preventDefault() and toggles .is-open on 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 in css/site-chrome.css.
  3. 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 MutationObserver plus an mvLangChange listener. 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:

ExcludedWhy
404.htmlDeliberately 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#

FailureSymptomCauseFix
An edit to the navbar vanishesThe change is gone after someone runs the syncThe chrome was edited inside a pageEdit partials/site-nav.html, run --write, commit both
CI or the pre-commit check fails on a page you did not touchExit 1 from the drift checkSomeone edited a page's chrome, or a partial changed without a re-syncnode tests/site-chrome-sync.mjs --write
A chrome change does not appear for returning visitorsOld navbar, new page content/css/* and /js/* carry a four-hour cacheBump CHROME_CSS_V and CHROME_JS_V, re-run --write, and bump CACHE_NAME in sw.js
White text on a white navbarUnreadable header on a free-tools pageA page defines part of the palette but not --panel, or the chrome was re-authored dark-firstKeep site-chrome.css light-first; do not depend on [data-theme="light"] being present
A page renders dark for everyoneThe theme toggle appears to do nothingA theme: pin was re-added to that page's entryRemove it. The pages are theme-aware now
A shared nav rule is beaten by page CSSOnly one page looks wrongThat page's inline <style> matched a .mv-* nameRename. The namespace exists so nothing else uses those names
A new page has no navbarChrome missing entirelyIt is not in PAGESAdd it, run --write, and wire it for translation too
The language pill shows a full language nameNavigation reflows on PortuguêsThe relabelling never ran, or a third mount was addedBoth mounts are relabelled by mv-site-chrome.js through an observer and the mvLangChange event
A media query string turns up in the translation baselineFive languages want to translate (max-width: 860px)A bare string literal was added to mv-site-chrome.jsCompare widths numerically, as the existing code does
A page diff is 1,700 lines for a 100-line changeUnreviewable commitLine endings were normalisedThe script preserves the dominant ending; do not run a formatter over these files

See also

Was this article helpful?