Skip to content
Metric VaultHelp Center
Open app

Building a result renderer

How to add a renderer that draws a tool result from the shared .mvr-* vocabulary, register it in the dispatch chain, and get the recommendations panel for free.

Last updated 2026-08-06

Summary#

A renderer is the function that turns one tool's API payload into the markup a customer reads. Every tool run ends in window.mvRenderOld(res, data, type, query), which walks a fixed dispatch chain and calls the first renderer that matches the tool type. This page is how to write one that fits the system instead of adding to the debt.

The rule the design system asks for is short: renderers describe, the shell composes. The rule the code enforces today is narrower, because the shell function does not exist yet. Read the status section before you start, then follow the steps. Done correctly, a new renderer inherits the verified-source header, the Get Recommendations panel, the exec-summary tab bar, the export engine and the six-language translation pass without writing a line for any of them.

Purpose#

The audit behind The design system found roughly thirty-nine of fifty-five tools shipping a bespoke renderer that hand-coded its own layout, spacing and color inline. That is why the app read as fifty-five products. The prime directive is that only the content of a result changes between tools, never the frame.

A renderer built from the shared vocabulary also costs less to maintain. The shared layer already knows how a KPI tile looks in both themes, how a table behaves on a phone, what an empty state says, and how the entrance animation staggers. Every one of those you rebuild is one you now own forever.

Status: the shell function does not exist#

docs/DESIGN-CONSTITUTION.md describes mvRenderResultShell(el, view), a single function that would take a plain view object and compose the whole result. A repository-wide search finds that name only inside that document. Constitution Phase 1 has not shipped.

What exists today is the .mvr-* CSS vocabulary, 103 class names across css/mvr.css and css/mvr-premium.css, and renderers in dashboard.html that emit .mvr-* markup by hand. So the practical instruction is: build your markup out of the shared primitives, keep the data-shaping and the markup-building in separate functions, and your renderer will be trivially convertible when the shell lands.

Warning

Warning: two components named in the constitution's table were never implemented. .mvr-card is not defined in any stylesheet, and the table class is .mvr-table, not .mvr-tbl. There are stray uses of both wrong names in dashboard.html that style nothing. Use .mvr-table, and use .mvr-section plus your own content for a section rather than .mvr-card.

Requirements#

  • The tool already returns data from /api/tools or /api/premium-ai. Adding the backend half is Adding a new tool.
  • The tool's view in dashboard.html carries the three contract elements: input[data-in], button[data-run="<tool_type>"] with data-kind="ai" or data-kind="tool", and div.mv-results[data-res].
  • Node 24 for the verification scripts.
  • You have read the six laws in The design system. Every one applies to renderer output.

Permissions#

dashboard.html belongs to the main branch. If you are on redesign, do not edit it. The file is 7.2 MB with CRLF line endings and two developers share the repository, so take turns: two people editing it at once produces a merge nobody can resolve. Stage explicit paths and never git add -A. The full ownership rules are in Code conventions and file ownership.

dashboard.html → the <script> block defining window.mvRenderOld → the BES map inside it.

Step-by-Step Guide#

1. Choose your dispatch slot#

mvRenderOld tries five things in order and stops at the first hit:

OrderMechanismCalled as
1BES, a map of about forty tool types to a named premium rendererW[name](res, data, opts)
2AIINTEL: prompt_research, ai_competitor_research, brand_performance, ai_questions, prompt_tracking, media_monitoringmvRenderAiIntelPremium(type, res, data)
3BATCH6: ad_clarity, pla_researchmvRenderBatch6Extras(type, res, data)
4INLINE, twelve tools mapped to mv_inline_* functionsW[name](res, data, query)
5mvRenderAny(res, data, {}), the generic JSON renderer

If nothing matches, the last resort writes a card reading No legacy renderer for <type>. Seeing that string in the browser means your registration did not take.

For a new standalone tool, use BES. The second element of each entry is the options object passed as the third argument, and it is one of four shapes: {idPrefix, query}, {idPrefix}, {query}, {brand}, or null to call the renderer with two arguments only.

2. Write the renderer as two functions#

Keep the data work and the markup work apart. That separation is the whole point, and it is what makes the eventual move to a real shell mechanical.

js
// 1. describe: pure, testable, no DOM, no strings of markup
function keywordPulseView(data) {
  return {
    hero:    { domain: data.domain, source: data._dataSource },
    metrics: [
      { label: 'Keywords',  value: data.total,   sub: 'tracked',  tone: 'neutral' },
      { label: 'Top 10',    value: data.top10,   sub: 'positions', tone: 'good' },
      { label: 'Lost',      value: data.lost,    sub: 'this month', tone: 'bad' }
    ],
    rows: data.keywords || []
  };
}

// 2. compose: builds .mvr-* markup only
window.mvRenderKeywordPulsePremium = function (res, data, opts) {
  var v = keywordPulseView(data || {});
  if (!v.rows.length) {
    res.innerHTML = '<div class="mvr"><div class="mvr-empty">' + ICON_INFO +
      '<div>No ranking data for that domain yet.</div></div></div>';
    return;
  }
  res.innerHTML = '<div class="mvr">' + head(v.hero) + kpis(v.metrics) + table(v.rows) + '</div>';
};

3. Compose from the primitives#

The container res already carries class="mvx" and a data-theme attribute, both set by mvRenderOld before it dispatches. Your markup goes inside a <div class="mvr"> wrapper.

NeedMarkup
Hero header.mvr-head containing .mvr-fav (favicon), .mvr-id with .mvr-domain and .mvr-meta
Metric row.mvr-kpis of .mvr-kpi, each with .mvr-kpi-top + .mvr-kpi-label, .mvr-kpi-val, .mvr-kpi-sub
Section heading.mvr-section with an inline SVG, .mvr-section-t, optional .mvr-section-sub
Table<table class="mvr-table">, class="num" on numeric cells for right alignment and tabular figures
Status badge.mvr-tier plus one of .good, .warn, .bad, .info, .brand, .neutral
Callout.mvr-insight for a finding, .mvr-note for a footnote
Buttons.mvr-cta primary, .mvr-ghost secondary, .mvr-chip for chips
Empty state.mvr-empty, with .is-error for the error variant
Loading.mvr-skel-row with .mvr-spin, .mvr-skel-grid of .mvr-skel-tile
Entrance animation.mvr-anim on a block, .mvr-stagger on its parent

.mvr-table styling is scoped under .mvx, so it only takes effect inside a result container. Four rules govern everything you write:

  • No style=. Add a class. If two tools need it, add it to css/mvr.css once, not to your renderer.
  • No raw hex. Color comes from css/tokens.css or from .mvr-tier.
  • No emoji. Inline SVG only.
  • No <div class="glass-card">. That is the legacy frame, and 382 occurrences remain in dashboard.html waiting to be removed.

Escape every value that came from an API. window._esc is available globally, defined in js/mv-polyfills.js, along with _tonePal and _pal. Those three are referenced by bare name across the premium renderers, which is why that file must not be reordered relative to the inline blocks.

4. Register it#

Add one entry to the BES map inside window.mvRenderOld:

js
keyword_pulse: ["mvRenderKeywordPulsePremium", iq],

iq is {idPrefix:"", query:q}, ip is {idPrefix:""}, qq is {query:q} and br is {brand:q}. Pick the one your renderer reads.

5. Do not attach the shared panels yourself#

A function named verified() runs after every successful dispatch path. It calls mvAttachVerifiedHeader(res, data, {}) and then mvGuideAttach(res, type, data, query). So the verified-source header and the Get Recommendations panel land on your tool automatically. mvAttachVerifiedHeader returns immediately unless the payload carries _dataSource or _dataSourceVerified, and it will not add a second header if one is present.

6. Add a recommendations rule#

The panel appears only if a rule produces findings. Rules live in js/mv-guide-rules.js, keyed by the same tool type the API uses, so adding a tool means adding an entry there rather than editing the monolith.

js
keyword_pulse: {
  label: "Keyword Pulse",
  context: function (d, q) { return "the keyword profile of " + (str(d && d.domain) || q); },
  viewMap: V_TECH,
  build: function (d) {
    var out = [];
    if (has(d.lost) && n(d.lost) > 0)
      out.push({ sev: "high", t: n(d.lost) + " keywords lost a top-10 position this month", area: "Keywords" });
    return out;
  }
}

A finding is { sev: 'high' | 'med' | 'low', t, area }. finalize sorts by severity, caps the list at six and assigns ids. area drives the Fix this in <area> deep link through viewMap.

The one hard rule: a rule fires only on evidence. A finding built from a field the payload never contained sends a customer to fix something the tool did not measure, which is worse than no panel. Every accessor must be null-safe, and a rule that finds nothing returns [] so no button is drawn.

7. Keep the strings translatable#

Every label you write is UI. The extractor reads template-literal markup inside <script> blocks, which is where most result labels live, so plain text is picked up automatically. Concatenations such as n + ' keywords' are not, and are handled instead by the dynamic result translator, which covers a fixed selector list including .mvr-section-t, .mvr-kpi-label, .mvr-kpi-sub, .mvr-tier, .mvr-empty, .mvr-insight and .mvr-note. Staying inside those classes is what makes a sentence translate. Details and the four-command pipeline are in Localization system.

8. Run the gates#

text
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/guide-rules.mjs                # every rule, three ways
node tests/guide-wiring.mjs               # the panel actually reaches the DOM
BASE_URL=… node tests/smoke.mjs --tools=keyword_pulse

tests/smoke.mjs exists because seventeen tools once "worked", in that the button clicked and the spinner showed, while the renderer silently threw a ReferenceError. Run it against a deployed preview before you call this done. All of the scripts are catalogued in Verification scripts.

Troubleshooting#

SymptomLikely causeFix
The result shows No legacy renderer for <type>The BES key does not match the data-run value, or the function name is misspelledThe map key is the tool type the API uses, and mvRenderOld checks typeof W[name] === "function" before calling
The spinner never clearsYour renderer threwmvRenderOld catches, logs [mv] renderer failed for <type>, then falls back to mvRenderAny. Open the console
A generic JSON dump appears instead of your layoutSame as above, or dispatch reached step 5Check the console for the caught error
_esc is not definedScript order changedjs/mv-polyfills.js must stay immediately after css/legacy-mvx.css
Everything renders inside a black box in dark modeThe .mvx wrapper defines its own pure-black --bg, which children inheritcss/mvr.css already neutralises this. Confirm your markup is inside .mvr and that mvr.css is still linked after the legacy sheet
Design lint fails after your changeA hex, an inline style=, an emoji or an off-scale radiusReplace them. Do not run --update-baseline to clear a failure
Your table looks unstyledYou used .mvr-tblThe real class is .mvr-table
A \n search-and-replace matched nothingdashboard.html is CRLFUse \r?\n
The change does not appear in local devwrangler pages dev dist serves dist/Copy the edited file into dist/
The change does not appear in production/js/* and /css/* are served with a four-hour cacheBump the ?v= query string, and bump CACHE_NAME in sw.js

FAQs#

Do I have to use .mvr-*, or can I write my own classes? Use .mvr-*. If the primitive you need genuinely does not exist, add it to css/mvr.css once and use it from there. A class defined inside a renderer is a new source of truth, and that is the failure the constitution exists to stop.

Where does the export code go? Nowhere. js/mv-export.js extracts one structured model from the rendered result and covers PDF, Excel, CSV and JSON for every tool with no per-tool code. Semantic markup, real <table> elements and honest headings are what make it work.

What makes the exec-summary tab bar appear? Two renderer families are handled differently. Results built from .mvr-section markers are grouped into panes, with the first pane always titled Summary, and that needs at least two segments. Results built from titled .glass-card blocks are categorised and shown or hidden non-destructively, and that needs at least three titled cards. Using .mvr-section puts you on the better path.

Can I skip the recommendations rule? Yes, and nothing breaks: no rule means no findings, which means no button. But before js/mv-guide-rules.js existed only about eighteen of fifty-eight tools had the panel, precisely because it was optional per tool. Add the rule.

How do I test a renderer without spending credits? js/tool-samples.js holds real captured results as window.MV_TOOL_SAMPLES, used by the See example preview. It is data rather than UI and is deliberately excluded from the translation scanner, so do not add UI copy to it.

Why is mvRenderOld called "old" if it is the current entry point? It is the legacy dispatcher that the Result Shell is meant to replace. It is still the only path from a tool run to a rendered result, so treat the name as history rather than a warning.

See also

Was this article helpful?