Frontend architecture
How dashboard.html is put together - the three-column shell, the 106-view router, the renderer dispatch chain, and every file in js/ and css/ with what it owns.
Last updated 2026-08-06
Summary#
The customer application is a single HTML document. dashboard.html is 7,223,279 bytes and 64,435 lines, uses CRLF line endings, and contains 106 view containers, every one of them present in the DOM from first paint and shown or hidden by a client-side router. There is no framework, no bundler and no build step: the scripts in js/ are classic scripts, the stylesheets in css/ are plain CSS, and load order is source order.
That single-document design is the reason several things look unusual. Global window.* assignment is the module system. A <link> placed 31,000 lines into the body is how one stylesheet is made to beat another. And the twenty-four files in js/ were each lifted out of an inline <script> and re-linked at the exact position they occupied, so that parse and execution order survived the extraction.
Purpose#
The frontend serves one goal: a customer should be able to move between seventy tools without a page load, without losing state, and without the app feeling like seventy products. Everything below follows from that, plus the hard constraint that there is nothing to compile.
Keeping all 106 views in one document means switching tools costs a hidden attribute rather than a network round trip. The price is a very large file and a load order that must be respected. Extracting behavior into js/ reduces that file and makes the pieces independently reviewable, which is why the extraction rules are strict about not changing when anything runs.
The visual half of "one product, not seventy" is a separate topic with its own enforcement; see The design system.
Architecture#
dashboard.html
├── #authOverlay client-side gate, hidden once Supabase resolves
└── .app (CSS grid: 92px | 248px | 1fr)
├── aside.rail 12 section icons + hover flyout
├── aside.side tool list for the active section, account block
└── div.main the only scroll container
├── header.topbar back, favorites, notifications, search,
│ credits, country, range, theme, user menu
├── nav.mv-crumbs Dashboard > Section > Tool
└── 106 × div.view exactly one visible at a time
├── the tool's own inputs ([data-in], button[data-run])
├── div.mv-results[data-res] the result container
└── div.mvte the tool explainerThe router#
A single IIFE owns navigation. It is plain history API work, no library.
| Concern | Implementation |
|---|---|
| View map | var views = { key: document.getElementById("view-<key>") }, 106 entries |
| Default view | dashboard |
| URL format | #/<view> for every view except the default, which gets a clean path so the URL never reads /dashboard#/dashboard |
| Section maps | VIEW_SECTION and SECTION_FIRST, both derived from SECTIONS at boot |
| Public API | window.mvGoTo(view, push), and window.mvNavTo(view) which routes through it |
| Legacy alias | #imagetool rewrites to imgresize, because the old combined Image Tool was split into Resize and Reformat |
goTo(v, push) lights up the rail, re-renders the section panel, marks the nav item active, sets the panel heading, renders breadcrumbs, hides every other .view, resets .main scroll to the top, then pushes or replaces history. Views with no section, reached only from in-app buttons, leave the sidebar alone but still record a route so a reload lands back on them.
Per-view lazy initialisation#
Views that need setup do it once, the first time they become visible. A MutationObserver per .view watches for the hidden attribute being removed and calls window.mvOnViewOpen(v), which runs that screen's init behind a mvIOnce guard. Screens wired this way include kmanager, cmanager, cmonitor, rank, team, scheduled, integrations, auto, myreports, history, account and dashboard.
The tool contract#
Every tool view shares three elements, and the delegated handlers depend on exactly these:
| Element | Role |
|---|---|
input[data-in] | The query field. Enter submits |
button[data-run="<tool_type>"] with data-kind="ai" or "tool" | The run trigger |
div.mv-results[data-res] | Where the result is written |
runToolButton(btn) is the generic runner. It refuses with Enter a value first. when the input is empty and Please sign in to run this tool. when there is no session, writes Analyzing… this can take a few seconds for live data. into the result container, scrolls it into view, then calls window.callTool or window.callPremiumAI depending on data-kind. On success it stashes the payload on view.__mvLastData, records a recent target, and hands off to window.mvRenderOld. A quota rejection renders Monthly limit reached — upgrade to continue. and anything else renders the escaped error or Something went wrong.
Renderer dispatch#
window.mvRenderOld(res, data, type, query) is the one entry point from a tool run to a rendered result. It adds class="mvx" to the result container and stamps data-theme on it, then walks a fixed chain and stops at the first match:
BES, a map of about forty tool types to a named premium renderer, called asW[name](res, data, opts)whereoptsis one of{idPrefix, query},{idPrefix},{query},{brand}, or omitted.AIINTEL(prompt_research,ai_competitor_research,brand_performance,ai_questions,prompt_tracking,media_monitoring) tomvRenderAiIntelPremium(type, res, data).BATCH6(ad_clarity,pla_research) tomvRenderBatch6Extras.INLINE, twelve tools mapped tomv_inline_*functions.mvRenderAny(res, data, {}), the generic JSON renderer.- A last-resort card reading
No legacy renderer for <type>.
After every successful path a shared verified() step runs mvAttachVerifiedHeader and then mvGuideAttach, so the verified-source header and the Get Recommendations panel land on every tool without each renderer remembering to add them. How to write one of these correctly is Building a result renderer.
Result states#
Four states share one vocabulary across all tools: .mv-loading (with a spinner), .mv-error (red tinted), .mv-note (used for quota) and .mvr-empty. Several other components treat a container holding only one of those as "no result yet", which is how the export gate and the exec-summary tab bar avoid firing on a spinner.
Components#
Every file in js/#
Twenty-four scripts plus a vendored SDK. The convention, stated in js/README.md, is classic scripts only: no ES modules, no bundling, no top-level const or let, no side-effect IIFEs, and each file linked at the exact position of the <script> block it replaced.
| File | Bytes | What it owns |
|---|---|---|
chapter-demos.js | 6,720 | Animates the nine mini-dashboards in the homepage chapters timeline. Values live in data-count, data-pct, --w and --h attributes so the markup is the source of truth. Honours reduced motion; if it never runs, CSS shows every end state |
export-gate.js | 2,107 | Disables a tool's export and share buttons until a finished result exists, and re-disables on a new run. A lone loading, error, note or empty child does not count as a result. Wired to Keyword Overview only, as the reference implementation |
kgap-vs.js | 2,075 | Gap Finder: keeps the two visible domain fields in sync with the single hidden [data-in] the backend reads as "you vs competitor" |
mv-advtech.js | 14,864 | Advanced Technical Checks. One URL, four zero-cost checks run in parallel against /api/tools and rendered as one report. Carries its own what and why copy so the pre-run state explains itself |
mv-blog.js | 239,357 | Blog Studio, a full multi-site blog CMS inside the app. Every call is scoped by an x-mv-site header and proxied to the standalone blog platform. Lazy-initialised on first open |
mv-charts.js | 20,909 | The shared chart system over Chart.js. Exposes mvBarChart, mvHBarChart, mvAreaChart, mvLineChart, mvDonutChart and mvScatterChart; each returns the instance or null and never throws. Publishes MV_CHART_PALETTE and MV_CHART_SEMANTIC |
mv-combined.js | 7,954 | Merged tools: running a group's primary view also runs its siblings with the same input. The sibling's .mv-results node is moved, not cloned, so canvases and handlers survive, and returned home before each re-run |
mv-content-checks.js | 19,941 | Plagiarism Checker and Fact Checker. Score ring, verdict banner, stat tiles, evidence. The ring value is an SVG presentation attribute, never inline CSS |
mv-export.js | 31,071 | The shared export engine for PDF, Excel, CSV and JSON. Extracts one structured model from the rendered result so there is no per-tool export code. PDF is real vector text via jsPDF and autoTable |
mv-guide-rules.js | 39,313 | The Get Recommendations rule table, keyed by tool type. A rule's build() returns findings { sev, t, area }. Rules must fire only on evidence; an empty result draws no button. Also loadable in Node so tests/guide-rules.mjs can exercise every rule |
mv-helpers.js | 5,348 | Utilities extracted verbatim from the monolith, including mvRelativeTime and mvDownloadCsv |
mv-i18n-dict.<lang>.js | es 2,949,060 · pt 2,919,872 · de 2,973,662 · fr 3,010,267 · ru 3,990,234 | Generated. One statement assigning window.MV_I18N, keyed by the English source string. Never edit by hand. Loaded lazily, only when a non-English language is first applied |
mv-i18n-runtime.js | 11,742 | The shared translation runtime for every page except dashboard.html. Text pass, attribute pass, observer, and the language switcher widget. See Localization system |
mv-library.js | 44,000 | Saved Work as a two-level file explorer: tool folders, a folder tree, breadcrumb navigation, grid and list layouts, cross-folder search and the result viewer. Filtering and ordering are done in SQL, not the browser |
mv-merge-tabs.js | 5,640 | Injects a tab strip at the top of each view in a merge group, so merged tools keep their own views while sharing one nav entry |
mv-modal.js | 16,499 | Accessible replacements for the native dialogs: mvAlert, mvConfirm and mvPrompt, all promise-returning. Default buttons are OK, Confirm and Cancel. Localises its own chrome at the source. Shared file: index, pricing, dashboard and admin all load it |
mv-particles.js | 7,087 | A vanilla port of a React particles component, because there is no build step. Color comes from a CSS custom property on the mount so it follows the theme |
mv-polyfills.js | 13,470 | Five guarded blocks lifted from the monolith. Defines the globals _esc, _tonePal and _pal that the top-level premium renderers reference by bare name. Do not reorder relative to the inline blocks |
mv-psi.js | 31,081 | The PageSpeed Insights report renderer, shared by the free tool page and the dashboard view. MVPSI.render(el, data, opts) returns true if it rendered the rich report and false if the host should fall back |
mv-reveal.js | 4,632 | Scroll reveal for cards. Engages only when IntersectionObserver exists and motion is allowed; if it never engages, cards are simply always visible |
mv-site-chrome.js | 5,478 | Behavior for the shared public navbar: mobile hamburger, the collapsible free-tools submenu below 860px, and language-pill relabelling. See Centralized nav and footer |
rec-panel.js | 22,868 | The Get Recommendations panel controller. Places an invitation under the result and an action in the tool's tab bar, runs staged loading, and calls /api/premium-ai |
tool-explainer.js | 9,861 | Motion and interactivity for every .mvte explainer on the page, fully scoped so several can coexist. Demo values come from data attributes so each tool is self-describing |
tool-samples.js | 467,002 | window.MV_TOOL_SAMPLES, real captured tool results used by the See example preview. Data, not UI, and deliberately excluded from the translation scanner |
vendor/supabase-2.38.0.umd.js | 98,746 | The self-hosted Supabase SDK used by the sign-in page, with a CDN fallback |
Three more scripts live at the repository root rather than in js/: chat-widget.js (the assistant, one engine on three surfaces), pwa-install.js (the install banner) and blog-embed.js (the third-party embed). chatbot.js is a deliberate no-op stub, explained in Repository structure. mobile.css used to sit beside them, orphaned; it was removed along with index-legacy.html, the only page that still linked it.
Every file in css/#
Twenty-four stylesheets. Same convention: plain <link> tags, no preprocessor, each linked at the original position of the <style> block it replaced so the cascade is unchanged.
| File | Bytes | Namespace and role |
|---|---|---|
tokens.css | 4,051 | The single source of truth for color, spacing, radius, shadow and type. Light :root, dark under [data-theme="dark"]. Loaded first |
app.css | 105,939 | Dashboard chrome lifted from the first inline style block: the three-column shell, rail, sidebar, topbar, cards, view chrome. Re-declares :root with the dark palette and puts light behind [data-theme="light"] |
legacy-mvx.css | 1,050,377 | The frozen .mvx stylesheet, 12,046 lines, lifted byte for byte. Holds the eleven forked token definitions the ratchet tracks. May only shrink |
mvr.css | 44,883 | The .mvr-* result component system: quick preview, the premium frame layer, result primitives, table polish, search and tool panels, Article Writer typography, the action bar, and the credit confirmation modal |
mvr-premium.css | 9,192 | An additive global premium layer loaded last in <head> so it wins over the legacy sheet without mutating it |
site-chrome.css | 24,088 | The public navbar and footer, namespaced .mv-*. Authored light-first |
chapter-demos.css | 20,723 | The nine homepage mini-dashboards, .cd-*. Index only. Each chapter gets its own layout on purpose; what they share is surface, type scale and motion |
blog.css | 72,633 | Blog Studio, .mvblog-*. Documents three click-feedback layers: :active, :focus-visible, and a JS-set .is-busy |
tool-explainer.css | 23,767 | The in-app explainer, all classes .mvte-* scoped under .mvte |
rec-panel.css | 16,832 | The recommendations panel, .mvrec-* |
psi.css | 23,194 | The PageSpeed report, scoped under .psi-report and single-theme light on purpose to match Google. Deliberately outside the design-lint targets so it can keep Google's exact palette |
library.css | 13,231 | The Library activity view, .mvlib-* |
library-saved.css | 18,023 | The "you already ran this" prompt and the saved-results list, .mvsv-* |
responsive-analyzer.css | 24,068 | The Responsive Website Analyzer, .ra-*. Token-only. Forces [hidden] { display: none !important } inside its view so display: grid cannot beat the user-agent rule |
image-tool.css | 27,069 | The Image Tool, .it-*. All processing is in-browser via Canvas |
content-checks.css | 14,379 | Plagiarism and Fact Checker, .mvcc-* |
content-decay.css | 3,049 | Content Decay Detector, .cd-* scoped under #view-contentdecay. Shares a prefix with chapter-demos.css but no class name collides and the two are never on one page |
advtech.css | 8,730 | Advanced Technical Checks, .mvat-* |
social.css | 12,214 | The connected-channels panel, .mv-soc-*. Brand colors live here on purpose, justified by being outside the ratchet so six real logo colors do not become a second token source |
notifhub.css | 3,252 | Notifications and Alerts Hub, .nh-*. Kept out of the monolith specifically so the ratchet holds |
merge-tabs.css | 1,050 | The merged-tool tab strip, .mvmg-* |
tech-guide.css | 5,129 | The technical-SEO fix guide panel, .mvtg-* |
gbp.css | 2,889 | The Google Business Profile details card, .gbp-* |
dev-placeholder.css | 1,809 | The placeholder shown by tools announced in the nav but not yet built, .mvdev-* |
Data flow#
Load order in dashboard.html#
Cascade order is source order, and several rules depend on it. The positions below are exact.
| Line | Asset | Why here |
|---|---|---|
| 6 | js/mv-modal.js | Not deferred, before <title>, so mvAlert exists for anything that runs early |
| 16 | Inline theme boot | Must be in <head> above the first stylesheet, or the page paints one theme and snaps to the other |
| 20 to 26 | tokens.css, app.css, responsive-analyzer.css, image-tool.css, library-saved.css, psi.css, tool-explainer.css | app.css after tokens.css is what makes the dashboard dark-first |
| 27 to 31 | tool-explainer.js, export-gate.js, rec-panel.css, rec-panel.js, kgap-vs.js | The four deferred js/ files |
| 28093 to 28100 | Chart.js 4.4.1, html2pdf 0.10.1, xlsx 0.18.5, jsPDF 2.5.1, jspdf-autotable 3.8.2, then mv-export.js | The export engine must load after its vendors |
| 28102 to 28103 | legacy-mvx.css, then mv-polyfills.js | The polyfills sit immediately after the legacy sheet by design |
| 42782 to 42793 | mv-helpers, mv-charts, tool-samples, mv-reveal, mv-guide-rules, mv-blog, mv-library, mv-merge-tabs, mv-advtech, mv-combined, mv-psi, mv-content-checks | The component block |
| 59712 to 59724 | tech-guide, mvr, mvr-premium, blog, library, social, dev-placeholder, gbp, notifhub, merge-tabs, advtech, content-decay, content-checks | This is why mvr.css beats legacy-mvx.css: it is linked 31,600 lines later |
| 61146 | chat-widget.js | Last |
js/mv-i18n-dict.<lang>.js never appears as a static tag. Both translation runtimes inject it lazily.
Theme#
The storage key is localStorage('mv-theme') with values dark and light, and the attribute is data-theme on <html>. Light is the default: tokens.css declares light in :root and puts dark behind [data-theme="dark"].
The dashboard is the one genuine exception to light-first. css/app.css re-declares :root with the dark palette and loads after tokens.css, so it wins, and light is restored by a [data-theme="light"] block plus roughly ninety-five component-level rules. That is safe only because the dashboard's boot script always writes an explicit data-theme. The toggle's label shows the target state: it reads Light while dark and Dark while light.
tests/theme-lint.mjs checks three things per page: that it loads css/tokens.css, that its boot script is inline in <head> before the first stylesheet, and that it declares no competing color variables. It covers nineteen pages and currently passes all nineteen.
Charts and canvas#
Canvas cannot read CSS custom properties, so every color reaching Chart.js goes through resolveColor(), which resolves var(--x) via getComputedStyle with a fallback. A color that skips it renders invisible or black. isDark() reads data-theme from <html> then <body> and defaults to dark when neither is set.
Failure modes#
| Failure | Symptom | Cause |
|---|---|---|
| A renderer throws | The result container keeps its spinner or shows the generic JSON fallback | mvRenderOld catches and logs [mv] renderer failed for <type>, then tries mvRenderAny. This class of silent failure is exactly what tests/smoke.mjs was written to catch |
A js/ file is reordered | Undefined globals at render time | The premium renderers reference _esc, _tonePal and _pal by bare name, defined in mv-polyfills.js |
| A stylesheet is moved | The wrong sheet wins | Cascade is source order. mvr.css only beats legacy-mvx.css because of where it is linked |
A literal </script> inside a template literal | SyntaxError: Unexpected end of input, whole page unclickable | This took production down once. tests/guard.mjs exists for it |
A broken inline <script> block | Everything after it stops executing | tests/chkblk.mjs compiles every inline block with vm.compileFunction and must report 0 broken |
A \n search-and-replace matches nothing | Your edit silently did not apply | dashboard.html and legal.html are CRLF. Use \r?\n |
| A shipped CSS or JS change does not appear | Cloudflare serves /js/* and /css/* with max-age=14400 | Bump the ?v= query string, and bump CACHE_NAME in sw.js |
| A local edit changes nothing | wrangler pages dev dist serves dist/, not the repository root | Copy the edited file into dist/. See Local development |
Gates to run before committing frontend work#
node tests/design-lint.mjs # no tracked count may rise
node tests/chkblk.mjs dashboard.html # 0 broken inline script blocks
node tests/guard.mjs dashboard.html # no </script> inside a template literal
node tests/site-chrome-sync.mjs # public chrome drift check
node tests/theme-lint.mjs # every page on the shared theme system
node tests/i18n-wire.mjs --check # every page wired for translationAdd node tests/login-inline-sync.mjs if login.html changed, and run the full translation pipeline if any user-visible string changed. Every script is described in Verification scripts.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.